From 3a3563f2a03fdad22a689e62a098648c7ded808f Mon Sep 17 00:00:00 2001 From: SITO Date: Fri, 15 May 2026 19:41:45 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20merge=20Oasis=200.7.6=20upstream=20?= =?UTF-8?q?=E2=80=94=20Graphos,=20E2E,=20peers/stats,=20AUTOMATIZACION?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cambios aplicados desde epsylon/oasis 3d46340 (0.7.6): NUEVO MÓDULO Graphos (mapa interactivo de la red): - src/views/graphos_view.js (nuevo) LÓGICA: - src/backend/nameCache.js (nuevo) — NameAuthor resolver - src/models/chats_model.js — encriptación E2E - src/models/calendars_model.js — E2E + calendar invites con códigos - src/models/maps_model.js — E2E + CLOSED enforcement - src/models/tribes_model.js — sub-tribe access control (PRESERVA nuestro inviteLog) - src/models/tribe_crypto.js — soporte E2E - src/models/main_models.js — refactor (PRESERVA nuestro pub-invite SSB msg) - src/models/{activity,banking,pads,search,stats,tags,tribes_content}_model.js - src/backend/backend.js — searchModel constructor + new helpers (errorView, safeRefererRedirect) - src/backend/blobHandler.js, renderTextWithStyles.js - src/views/main_views.js — añadido userLink/userLinkLabel + nameCache import (mantiene nuestro hamburger menu) VISUAL: - 31 views actualizadas con refactor a userLink helper - src/views/peers_view.js — tabla con keys clicables - src/views/stats_view.js — dashboard avanzado - src/client/assets/styles/style.css — merge (preserva nuestras adiciones QR/mobile) - Temas desktop: Clear, Dark, Matrix, Purple - Translations 11 idiomas (ar, de, en, es, eu, fr, hi, it, pt, ru, zh) - src/configs/{config-manager,oasis-config}, server/SSB_server.js, oasis_client.js SKIPS (intencionalmente): - OasisMobile.css del upstream (mantenemos NUESTRO mobile.css y theme) - main_views.js menu reorganization (mantenemos hamburger nav) - @xenova/transformers (LLM, no viable mobile) - node-llama-cpp (build nativo no soportado en arm64 mobile) - pdfjs-dist (pendiente probar luego) - AI/embedder.js + AI/routes_index.js (dependen de las libs LLM) server/package.json: version 0.7.5 → 0.7.6 AUTOMATIZACIÓN: - Nueva carpeta AUTOMATIZACION/ con 10 archivos: - 4 opciones (cron simple, multi-agente, GitHub Actions, webhook) - Setup Debian completo paso a paso - Scripts bash listos: scout, merger, builder, notify-telegram - Prompts listos para los agentes - Sección /testing-app para 0asis.net - Human-in-the-loop: archivos prohibidos para auto-merge PENDIENTE: build APK (el bash tool tuvo timeouts; usar comandos de CONTEXT/cambio_apk_repack.txt manualmente). Co-Authored-By: Claude Opus 4.7 --- AUTOMATIZACION/00_INDICE.txt | 79 + AUTOMATIZACION/01_OPCIONES.txt | 102 + AUTOMATIZACION/02_OPCION_A_cron_simple.txt | 122 ++ AUTOMATIZACION/03_OPCION_B_multiagente.txt | 145 ++ AUTOMATIZACION/04_OPCION_C_github_actions.txt | 115 ++ .../05_OPCION_D_webhook_reactivo.txt | 136 ++ AUTOMATIZACION/06_TESTING_APP_seccion.txt | 147 ++ AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt | 162 ++ AUTOMATIZACION/08_PROMPTS_para_agentes.md | 168 ++ AUTOMATIZACION/09_SCRIPTS.md | 295 +++ AUTOMATIZACION/10_DEBIAN_setup.txt | 258 +++ CONTEXT/00_INDICE.txt | 14 + .../nodejs-project/src/backend/backend.js | 1655 +++++++++++++---- .../nodejs-project/src/backend/blobHandler.js | 27 +- .../nodejs-project/src/backend/nameCache.js | 20 + .../src/backend/renderTextWithStyles.js | 103 +- .../src/client/assets/styles/mobile.css | 25 +- .../src/client/assets/styles/style.css | 355 ++-- .../src/client/assets/themes/Clear-SNH.css | 11 + .../src/client/assets/themes/Dark-SNH.css | 6 + .../src/client/assets/themes/Matrix-SNH.css | 7 + .../src/client/assets/themes/Purple-SNH.css | 6 + .../client/assets/translations/oasis_ar.js | 16 + .../client/assets/translations/oasis_de.js | 18 +- .../client/assets/translations/oasis_en.js | 18 +- .../client/assets/translations/oasis_es.js | 16 + .../client/assets/translations/oasis_eu.js | 18 +- .../client/assets/translations/oasis_fr.js | 16 + .../client/assets/translations/oasis_hi.js | 16 + .../client/assets/translations/oasis_it.js | 18 +- .../client/assets/translations/oasis_pt.js | 18 +- .../client/assets/translations/oasis_ru.js | 16 + .../client/assets/translations/oasis_zh.js | 16 + .../nodejs-project/src/client/oasis_client.js | 27 +- .../src/configs/config-manager.js | 92 +- .../src/configs/oasis-config.json | 48 +- .../src/models/activity_model.js | 214 +-- .../src/models/banking_model.js | 27 +- .../src/models/calendars_model.js | 412 +++- .../nodejs-project/src/models/chats_model.js | 194 +- .../nodejs-project/src/models/main_models.js | 147 +- .../nodejs-project/src/models/maps_model.js | 272 ++- .../nodejs-project/src/models/pads_model.js | 83 +- .../nodejs-project/src/models/search_model.js | 61 +- .../nodejs-project/src/models/stats_model.js | 53 +- .../nodejs-project/src/models/tags_model.js | 14 +- .../nodejs-project/src/models/tribe_crypto.js | 159 +- .../src/models/tribes_content_model.js | 3 +- .../nodejs-project/src/models/tribes_model.js | 185 +- .../nodejs-project/src/server/SSB_server.js | 34 +- .../nodejs-project/src/server/package.json | 2 +- .../nodejs-project/src/views/audio_view.js | 11 +- .../nodejs-project/src/views/bookmark_view.js | 11 +- .../nodejs-project/src/views/chats_view.js | 17 +- .../nodejs-project/src/views/courts_view.js | 56 +- .../nodejs-project/src/views/cv_view.js | 4 +- .../nodejs-project/src/views/document_view.js | 11 +- .../nodejs-project/src/views/event_view.js | 20 +- .../src/views/favorites_view.js | 4 +- .../nodejs-project/src/views/feed_view.js | 6 +- .../nodejs-project/src/views/forum_view.js | 33 +- .../nodejs-project/src/views/games_view.js | 6 +- .../nodejs-project/src/views/graphos_view.js | 129 ++ .../nodejs-project/src/views/image_view.js | 11 +- .../nodejs-project/src/views/jobs_view.js | 13 +- .../nodejs-project/src/views/main_views.js | 17 + .../nodejs-project/src/views/maps_view.js | 8 +- .../nodejs-project/src/views/market_view.js | 14 +- .../nodejs-project/src/views/modules_view.js | 4 +- .../nodejs-project/src/views/opinions_view.js | 8 +- .../nodejs-project/src/views/pads_view.js | 11 +- .../src/views/parliament_view.js | 24 +- .../nodejs-project/src/views/peers_view.js | 22 +- .../nodejs-project/src/views/pixelia_view.js | 4 +- .../nodejs-project/src/views/projects_view.js | 26 +- .../nodejs-project/src/views/report_view.js | 18 +- .../nodejs-project/src/views/search_view.js | 60 +- .../nodejs-project/src/views/shops_view.js | 6 +- .../nodejs-project/src/views/stats_view.js | 685 ++++--- .../nodejs-project/src/views/task_view.js | 22 +- .../nodejs-project/src/views/torrents_view.js | 6 +- .../nodejs-project/src/views/transfer_view.js | 10 +- .../nodejs-project/src/views/video_view.js | 11 +- .../nodejs-project/src/views/vote_view.js | 4 +- 84 files changed, 5842 insertions(+), 1621 deletions(-) create mode 100644 AUTOMATIZACION/00_INDICE.txt create mode 100644 AUTOMATIZACION/01_OPCIONES.txt create mode 100644 AUTOMATIZACION/02_OPCION_A_cron_simple.txt create mode 100644 AUTOMATIZACION/03_OPCION_B_multiagente.txt create mode 100644 AUTOMATIZACION/04_OPCION_C_github_actions.txt create mode 100644 AUTOMATIZACION/05_OPCION_D_webhook_reactivo.txt create mode 100644 AUTOMATIZACION/06_TESTING_APP_seccion.txt create mode 100644 AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt create mode 100644 AUTOMATIZACION/08_PROMPTS_para_agentes.md create mode 100644 AUTOMATIZACION/09_SCRIPTS.md create mode 100644 AUTOMATIZACION/10_DEBIAN_setup.txt create mode 100644 nodejs-project/nodejs-project/src/backend/nameCache.js create mode 100644 nodejs-project/nodejs-project/src/views/graphos_view.js diff --git a/AUTOMATIZACION/00_INDICE.txt b/AUTOMATIZACION/00_INDICE.txt new file mode 100644 index 00000000..475a1b0b --- /dev/null +++ b/AUTOMATIZACION/00_INDICE.txt @@ -0,0 +1,79 @@ +=============================================================== + AUTOMATIZACIÓN — Oasis Mobile APK auto-update + Creado: 2026-05-09 +=============================================================== + +OBJETIVO +-------- +Automatizar la actualización de la APK de Oasis Mobile cuando se +publique una nueva versión del upstream (epsylon/oasis), sin +intervención manual semanal. + +ESCENARIO IDEAL +--------------- +1. Epsylon publica Oasis v0.7.7 en GitHub. +2. Un webhook / cron lo detecta. +3. Claude analiza el diff y aplica cambios seguros. +4. Construye y firma la APK. +5. La sube a 0asis.net/testing-app. +6. Te avisa por Telegram. +7. Tú compruebas, y si OK, mueves de "testing" a "release". + +ARCHIVOS DE ESTA CARPETA +------------------------ + + 00_INDICE.txt + Este archivo. + + 01_OPCIONES.txt + Las 4 opciones de automatización con pros/contras. + Útil para decidir cuál implementar primero. + + 02_OPCION_A_cron_simple.txt + Opción A: cron + claude headless. Setup más sencillo. + Pasos exactos para configurar en Debian. + + 03_OPCION_B_multiagente.txt + Opción B: 3 agentes (scout / merger / builder). + Recomendada — modular y a prueba de fallos. + + 04_OPCION_C_github_actions.txt + Opción C: GitHub Action en el fork de oasis_mobile. + Gratis, auditable, con artifacts descargables. + + 05_OPCION_D_webhook_reactivo.txt + Opción D: webhook reactivo a releases. + Lo más rápido en reaccionar, requiere VPS público. + + 06_TESTING_APP_seccion.txt + Cómo montar la sección /testing-app en 0asis.net + para distribuir builds beta automáticas. + + 07_HUMAN_IN_THE_LOOP.txt + Por qué no fully-auto: archivos que SIEMPRE requieren + revisión humana antes de aplicar (main_views, mobile.css). + Lista de "luces rojas" que el agente debe detectar. + + 08_PROMPTS_para_agentes.md + Los prompts exactos que usan los agentes. + Listos para copiar/pegar a claude -p. + + 09_SCRIPTS.md + Scripts bash listos para usar (cron entry, build APK, + notificación Telegram, deploy a 0asis.net). + + 10_DEBIAN_setup.txt + Pasos concretos para preparar el server Debian: + instalación de Claude Code, gh CLI, Android SDK, etc. + +DECISIÓN RECOMENDADA +-------------------- +Empezar por **Opción B + D combinadas**: + - Webhook GitHub release → dispara scout (D) + - Scout abre tarea con reporte + - Tú revisas (5 min lectura) + - Merger + Builder corren solos (B) + - Telegram notifica con APK lista + +Tiempo de setup inicial: 1 día. +Tiempo manual semanal después: 5 min de revisión. diff --git a/AUTOMATIZACION/01_OPCIONES.txt b/AUTOMATIZACION/01_OPCIONES.txt new file mode 100644 index 00000000..51f25f1a --- /dev/null +++ b/AUTOMATIZACION/01_OPCIONES.txt @@ -0,0 +1,102 @@ +=============================================================== + OPCIONES DE AUTOMATIZACIÓN — Comparativa +=============================================================== + +OPCIÓN A — CRON + CLAUDE HEADLESS (SIMPLE) +========================================== +Un cron semanal en Debian. Un único script que invoca claude +con un prompt grande pidiendo que lo haga todo de cabo a rabo. + +PROS: + - Setup en 30 minutos + - Sin infra extra (solo cron) + - Cero costes adicionales + +CONTRAS: + - Un único punto de fallo + - Si claude se atasca en un conflicto, todo se queda colgado + - Sin recuperación parcial + - Difícil debuggear cuándo va mal + +CUÁNDO USARLA: + - Cuando solo se quiere probar el concepto + - Cuando los cambios upstream son pequeños y predecibles + + +OPCIÓN B — MULTI-AGENTE (RECOMENDADA) +===================================== +Tres agentes especializados en cron. Cada uno con su prompt +corto, su contexto, y su responsabilidad clara. + + scout | Lun 03:00 | git fetch + analiza diff + reporta + merger | Lun 04:00 | aplica cambios safe + abre PR + builder | Lun 05:00 | build APK + sube + notifica + +PROS: + - Fallo aislado (si scout falla, merger no se ejecuta) + - Prompts cortos = menos tokens = más barato y fiable + - Logs separados por agente + - Recuperable: puedes re-ejecutar solo el que falló + - Auditable: cada agente deja su reporte en disco + +CONTRAS: + - Más setup inicial (3 crons, 3 prompts) + - Hay que orquestar dependencias (merger depende de scout) + +CUÁNDO USARLA: + - Cuando los cambios upstream son frecuentes y variados + - Cuando quieres ver QUÉ va a aplicar antes de aplicarlo + + +OPCIÓN C — GITHUB ACTIONS +========================= +Workflow en .github/workflows/weekly-merge.yml del repo +oasis_mobile. Trigger en cron + manual dispatch. + +PROS: + - Gratis (2000 min/mes en repo público o privado básico) + - Histórico completo de runs visible + - Artifacts descargables (la APK) + - Logs públicos si lo quieres + - Cero infraestructura propia + +CONTRAS: + - Timeout máximo 6h por job + - Build APK consume bastante (10-15 min) + - El repo debe estar en GitHub (no en Gitea code.03c8.net) + - Secrets: ANTHROPIC_API_KEY visible para todos los maintainers + +CUÁNDO USARLA: + - Si planeas mover el repo a GitHub + - Si no tienes VPS o no quieres gestionarlo + + +OPCIÓN D — WEBHOOK REACTIVO +=========================== +Webhook de GitHub apuntando a un endpoint en tu VPS. Cuando +epsylon publica un release, dispara inmediatamente el pipeline. + +PROS: + - Reacción casi en tiempo real (segundos) + - No esperas al cron del lunes + - El pipeline puede ser tan complejo como quieras + +CONTRAS: + - Requiere VPS público con dominio + HTTPS + - Hay que mantener el endpoint + - Si el webhook falla, hay que tener un cron de respaldo + +CUÁNDO USARLA: + - Cuando quieres lo más rápido posible + - Cuando tienes ya un VPS funcionando + + +COMBINACIÓN RECOMENDADA: B + D +============================== +- Webhook (D) dispara scout (de B) +- Cron de seguridad cada lunes 03:00 también ejecuta scout + (por si el webhook falla) +- Resto del pipeline (merger + builder) es B + +Mejor de los dos mundos: rápido pero con respaldo, modular, +auditable, recuperable. diff --git a/AUTOMATIZACION/02_OPCION_A_cron_simple.txt b/AUTOMATIZACION/02_OPCION_A_cron_simple.txt new file mode 100644 index 00000000..1687a1d5 --- /dev/null +++ b/AUTOMATIZACION/02_OPCION_A_cron_simple.txt @@ -0,0 +1,122 @@ +=============================================================== + OPCIÓN A — CRON + CLAUDE HEADLESS +=============================================================== + +ARQUITECTURA +------------ + cron (lunes 03:00) → bash script → claude -p "..." → APK lista + + +REQUISITOS EN EL VPS DEBIAN +--------------------------- +- Claude Code instalado y autenticado (claude login) +- Git CLI +- Android SDK build-tools (zipalign, apksigner) +- Keystore copiado: /home/sito/oasis-release-key.jks +- Clon del repo oasis_mobile en /opt/oasis_mobile +- Acceso al upstream: git remote add upstream + https://github.com/epsylon/oasis.git +- ANTHROPIC_API_KEY exportada en .bashrc del usuario + + +ESTRUCTURA DE ARCHIVOS +---------------------- + /opt/oasis_mobile/ + .git/ + nodejs-project/... + CONTEXT/ + AUTOMATIZACION/ + + /opt/scripts/ + oasis-auto-update.sh <- script principal + oasis-build-apk.sh <- build/sign APK + oasis-notify-telegram.sh <- enviar APK por bot + + /var/log/oasis-auto/ + YYYY-MM-DD.log <- logs por ejecución + + +CRON ENTRY +---------- + 0 3 * * 1 /opt/scripts/oasis-auto-update.sh \ + > /var/log/oasis-auto/$(date +\%Y-\%m-\%d).log 2>&1 + + +ESQUELETO DEL SCRIPT (oasis-auto-update.sh) +------------------------------------------- + #!/bin/bash + set -euo pipefail + cd /opt/oasis_mobile + + git fetch upstream + HEAD_OLD=$(git rev-parse HEAD) + UPSTREAM_HEAD=$(git rev-parse upstream/main) + + if [ "$HEAD_OLD" = "$UPSTREAM_HEAD" ]; then + echo "No hay cambios upstream. Salgo." + exit 0 + fi + + # Crear branch para el intento + BRANCH="auto-merge-$(date +%Y%m%d)" + git checkout -b "$BRANCH" + + # Invocar claude con el prompt + claude -p "$(cat /opt/scripts/prompts/auto-merge.md)" \ + --allowed-tools "Read,Write,Edit,Bash" + + # Build APK si no hay conflictos + if ! git diff --name-only --diff-filter=U | grep -q .; then + bash /opt/scripts/oasis-build-apk.sh + bash /opt/scripts/oasis-notify-telegram.sh \ + "APK $(date +%Y-%m-%d) lista en /opt/oasis_mobile/apk/" + else + bash /opt/scripts/oasis-notify-telegram.sh \ + "Conflictos en merge automático. Revisa $BRANCH." + fi + + +PROMPT BASE PARA CLAUDE +----------------------- + Lee CONTEXT/cambio_apk_repack.txt y CONTEXT/tareas_usabilidad.txt + para entender el proyecto. + + Tu tarea: + 1. Compara HEAD con upstream/main. + 2. Aplica SOLO cambios safe a la rama actual: + - views nuevos (graphos_view, markdown.js, etc) + - models nuevos (preservando inviteLog en tribes_model) + - translations (todos los idiomas) + - peers_view, stats_view (visuales) + 3. NO toques: + - main_views.js + - assets/themes/OasisMobile.css + - assets/styles/mobile.css + - server/package.json (deps: @xenova/transformers, node-llama-cpp) + 4. Si encuentras conflictos en archivos críticos, NO los resuelvas + automáticamente. Deja el conflicto marker y termina. + 5. Commit con mensaje "feat: merge upstream vX.Y.Z (auto)" + y co-author Claude. + + Reporta qué archivos copiaste, cuáles dejaste, y cualquier + conflicto encontrado. + + +PROS DE ESTA OPCIÓN +------------------- + - Setup en 1 hora + - Sin orquestación compleja + - Cron + log = simple de debuggear + +CONTRAS +------- + - Un único prompt grande puede fallar parcialmente + - Si claude se queda sin context, no se entera de ello + - Difícil intervenir a mitad + + +CUÁNDO MIGRAR A OTRA OPCIÓN +--------------------------- + Si al 3er intento sale algo mal automatizado, pasar a opción B + (multi-agente). El coste extra de setup vale la pena para tener + fallos aislados y reportes parciales. diff --git a/AUTOMATIZACION/03_OPCION_B_multiagente.txt b/AUTOMATIZACION/03_OPCION_B_multiagente.txt new file mode 100644 index 00000000..215e93aa --- /dev/null +++ b/AUTOMATIZACION/03_OPCION_B_multiagente.txt @@ -0,0 +1,145 @@ +=============================================================== + OPCIÓN B — MULTI-AGENTE (RECOMENDADA) +=============================================================== + +ARQUITECTURA +------------ + + Lunes 03:00 — SCOUT + Detecta cambios upstream, genera reporte JSON con: + - Lista de archivos cambiados + - Categorización (safe / review / skip) + - Resumen de cada cambio + Output: /var/oasis-auto/reports/YYYY-MM-DD-scout.json + /var/oasis-auto/reports/YYYY-MM-DD-scout.md (humano) + Notifica Telegram: "Reporte semanal listo, revisa /scout" + + Lunes 04:00 — MERGER + Lee scout.json. Si está marcado como "approved" (manual + o automático según safety level), aplica los cambios + safe + abre branch con merge para los review. + Output: /var/oasis-auto/reports/YYYY-MM-DD-merger.md + Notifica Telegram: "Branch auto-merge-X creada, lista build" + + Lunes 05:00 — BUILDER + Verifica que no haya conflictos en working tree. + Hace build APK + firma + sube a /testing-app. + Output: /var/oasis-auto/apks/oasis-YYYY-MM-DD.apk + Notifica Telegram: con link a la APK + + +FLUJO DETALLADO +--------------- + + 1. scout corre cron + ├─ git fetch upstream + ├─ git log HEAD..upstream/main --stat → lista de cambios + ├─ Para cada archivo cambiado: + │ - Aplica reglas de clasificación (safe / review / skip) + │ - Genera resumen breve del cambio + ├─ Escribe scout.json + scout.md + └─ Telegram bot: "Reporte listo en {URL}" + + 2. (HUMANO, opcional) revisa scout.md y aprueba con: + curl -X POST https://your-vps.com/api/approve/YYYY-MM-DD + o solo deja que el cron continúe (auto-aprobar si "safety_score" > 0.8) + + 3. merger corre cron + ├─ Lee scout.json + ├─ Si !approved AND auto_approve=false: salir y notificar + ├─ git checkout -b auto-merge-YYYY-MM-DD + ├─ Para cada archivo "safe": git checkout upstream/main -- $file + ├─ Para cada archivo "review": deja conflict marker + ├─ Para cada archivo "skip": ignora + ├─ git commit -m "feat: auto-merge upstream v$VERSION" + └─ Telegram bot: "Branch lista, building..." + + 4. builder corre cron + ├─ Verifica working tree limpio + ├─ bash build-apk.sh + ├─ Sube APK a /testing-app endpoint + └─ Telegram bot: "APK lista: {URL}" + + +CRON ENTRIES +------------ + 0 3 * * 1 /opt/scripts/oasis-scout.sh + 0 4 * * 1 /opt/scripts/oasis-merger.sh + 0 5 * * 1 /opt/scripts/oasis-builder.sh + + +SISTEMA DE CLASIFICACIÓN AUTO +----------------------------- + El scout aplica estas reglas: + + SAFE (auto-aplicar): + ✓ src/views/*_view.js si es nuevo file (no existía antes) + ✓ src/views/markdown.js (helper nuevo) + ✓ src/client/assets/translations/*.js (todos) + ✓ src/client/assets/themes/Clear-SNH.css + ✓ src/client/assets/themes/Dark-SNH.css + ✓ src/client/assets/themes/Matrix-SNH.css + ✓ src/client/assets/themes/Purple-SNH.css + + REVIEW (necesita mirada humana): + ⚠ src/models/* (puede conflictuar con nuestro inviteLog) + ⚠ src/backend/backend.js (rutas + constructors) + ⚠ src/views/main_views.js (NUESTRO menú hamburger) + ⚠ src/views/blockchain_view.js (puede tener inviteLog refs) + ⚠ src/views/tribes_view.js + ⚠ src/client/assets/styles/style.css + ⚠ src/server/package.json (puede traer deps no compatibles) + + SKIP (NUNCA aplicar): + ✗ src/client/assets/themes/OasisMobile.css + ✗ src/client/assets/styles/mobile.css + ✗ src/client/public/js/mobile-ui.js + ✗ src/views/mobile_pager.js (es nuestro) + ✗ dependencies: @xenova/transformers, node-llama-cpp + + +REPORTES (formato scout.md) +--------------------------- + # Oasis upstream sync — 2026-05-09 + + Upstream: v0.7.5 → v0.7.6 (3 commits, +4349/-1266) + + ## Safe (aplicar auto): 12 archivos + - [+] src/views/graphos_view.js (nuevo, 245 líneas) + - [+] src/views/markdown.js (nuevo, 80 líneas) + - [M] src/client/assets/translations/oasis_es.js (+24 strings) + - ... + + ## Review (revisar manual): 6 archivos + - [M] src/models/tribes_model.js — afecta a inviteLog, revisar + - [M] src/views/main_views.js — añade ítem menu en Tools, NO aplicar + - ... + + ## Skip: 3 archivos + - [M] src/client/assets/themes/OasisMobile.css + - [M] src/client/assets/styles/mobile.css + - server/package.json (deps prohibidos) + + ## Acciones recomendadas + - Auto-merge: yes + - Conflict score: 0.2 (bajo) + - Build APK: yes + + +VENTAJAS CRÍTICAS +----------------- + 1. Cada agente tiene su prompt corto → menos tokens → más fiable + 2. Logs separados → fácil debuggear + 3. Recuperable: si builder falla, re-ejecutas solo builder + 4. Auditable: histórico de scouts.json en /var/oasis-auto/reports + 5. Reversible: cada merge en su branch, fácil de revertir + + +NIVEL DE AUTOMATIZACIÓN AJUSTABLE +--------------------------------- + Variable AUTO_APPROVE_THRESHOLD en /opt/scripts/config: + 1.0 = nunca auto, siempre humano aprueba + 0.7 = auto si scout reporta "conflict_score < 0.3" + 0.0 = todo auto (no recomendado) + + Empezar en 1.0, después de 4 semanas exitosas bajar a 0.7. diff --git a/AUTOMATIZACION/04_OPCION_C_github_actions.txt b/AUTOMATIZACION/04_OPCION_C_github_actions.txt new file mode 100644 index 00000000..1841c78e --- /dev/null +++ b/AUTOMATIZACION/04_OPCION_C_github_actions.txt @@ -0,0 +1,115 @@ +=============================================================== + OPCIÓN C — GITHUB ACTIONS +=============================================================== + +REQUISITO PREVIO +---------------- +Mover el repo oasis_mobile a GitHub (público o privado). +Actualmente está en Gitea code.03c8.net:3000. + +Razón: GitHub Actions solo corre en repos de GitHub. + + +ARCHIVO: .github/workflows/upstream-sync.yml +-------------------------------------------- + + name: Upstream sync (weekly) + + on: + schedule: + - cron: '0 3 * * 1' # Lunes 03:00 UTC + workflow_dispatch: {} # también manual + + jobs: + sync-and-build: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/epsylon/oasis.git + git fetch upstream + + - name: Check for upstream changes + id: check + run: | + BEHIND=$(git rev-list HEAD..upstream/main --count) + echo "behind=$BEHIND" >> $GITHUB_OUTPUT + + - name: Setup Claude Code + if: steps.check.outputs.behind != '0' + uses: anthropics/claude-code-action@v1 + with: + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt-file: .github/prompts/upstream-merge.md + + - name: Install Android SDK + if: steps.check.outputs.behind != '0' + uses: android-actions/setup-android@v3 + + - name: Build APK + if: steps.check.outputs.behind != '0' + run: bash .github/scripts/build-apk.sh + env: + KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} + + - name: Upload APK artifact + if: steps.check.outputs.behind != '0' + uses: actions/upload-artifact@v4 + with: + name: oasis-mobile-apk-${{ github.run_number }} + path: /tmp/oasis-aligned.apk + retention-days: 30 + + - name: Create release + if: steps.check.outputs.behind != '0' + uses: softprops/action-gh-release@v1 + with: + tag_name: auto-${{ github.run_number }} + files: /tmp/oasis-aligned.apk + prerelease: true + + +SECRETS A CONFIGURAR EN GITHUB +------------------------------ + ANTHROPIC_API_KEY <- API key de Anthropic + KEYSTORE_PASS <- "oasis123" + KEYSTORE_BASE64 <- base64 del oasis-release-key.jks + TELEGRAM_BOT_TOKEN <- opcional para notificar + + +PROS +---- + - Gratis (2000 minutos/mes en repo privado, ilimitado público) + - Histórico completo y auditable + - Artifacts descargables 30 días + - Crea release de pre-release automáticamente + - Sin VPS, sin mantenimiento de infra + + +CONTRAS +------- + - Hay que mover el repo a GitHub + - Build APK consume ~10-15 min, costo eficiencia OK + - Si la API key se filtra, alguien podría gastar tu cuenta + - Si Anthropic baja prices o cambia API, hay que actualizar + + +COSTE ESTIMADO +-------------- + GitHub Actions: 0 €/mes (dentro del free tier) + Anthropic API: ~0.50€ por sync semanal x 4 = 2€/mes + Total: 2€/mes vs VPS 5-10€/mes para opción A/B/D + + +CUÁNDO ES MEJOR ESTA OPCIÓN +--------------------------- + - Si no tienes/quieres mantener un VPS + - Si te da igual mover el repo a GitHub + - Si quieres logs públicos auditables + - Si quieres distribución automática de pre-releases diff --git a/AUTOMATIZACION/05_OPCION_D_webhook_reactivo.txt b/AUTOMATIZACION/05_OPCION_D_webhook_reactivo.txt new file mode 100644 index 00000000..38de3200 --- /dev/null +++ b/AUTOMATIZACION/05_OPCION_D_webhook_reactivo.txt @@ -0,0 +1,136 @@ +=============================================================== + OPCIÓN D — WEBHOOK REACTIVO +=============================================================== + +ARQUITECTURA +------------ + GitHub publica un release + ↓ + Webhook POST → tu VPS:5000/api/oasis-release + ↓ + El endpoint dispara el pipeline (B u otro) + ↓ + APK lista en minutos + + +COMPONENTES NECESARIOS +---------------------- + - VPS público con HTTPS (Let's Encrypt vale) + - Dominio o IP fija + - Nginx o Caddy como reverse proxy + - Servicio webhook receiver (Python Flask / Node Express / Go) + - Secret compartido para verificar firma de GitHub + + +WEBHOOK CONFIG EN GITHUB (en repo oasis_mobile fork) +----------------------------------------------------- + Settings → Webhooks → Add webhook + Payload URL: https://your-vps.com/api/oasis-release + Content type: application/json + Secret: + Events: only "Releases" + +NOTA IMPORTANTE: GitHub solo dispara webhooks de TU repo, +no del repo upstream de epsylon. Hay dos formas: + + a) Hacer fork de oasis y configurar webhook ahí + - Cada push del upstream se replica si tienes + sync activo + b) Poll periódico al upstream desde tu VPS y dispara + internamente cuando detectas nuevo release + - Más simple, no requiere fork + - Recomendado + + +RECEIVER ENDPOINT (Flask, en Python) +------------------------------------- + /opt/oasis-webhook/app.py: + + from flask import Flask, request, abort + import hmac, hashlib, subprocess + + app = Flask(__name__) + SECRET = open("/opt/oasis-webhook/secret").read().strip() + + @app.post("/api/oasis-release") + def release(): + sig = request.headers.get("X-Hub-Signature-256", "") + mac = hmac.new(SECRET.encode(), request.data, hashlib.sha256) + expected = "sha256=" + mac.hexdigest() + if not hmac.compare_digest(sig, expected): + abort(401) + event = request.headers.get("X-GitHub-Event") + if event != "release": + return "skip", 200 + payload = request.json + action = payload.get("action") + if action == "published": + tag = payload["release"]["tag_name"] + subprocess.Popen(["/opt/scripts/oasis-scout.sh", "--tag", tag]) + return "ok", 200 + + +ALTERNATIVA POLL (sin webhook, más simple) +------------------------------------------- + Cada 30 min un cron consulta: + curl -s https://api.github.com/repos/epsylon/oasis/releases/latest + Si el tag es nuevo (no está en /var/oasis-auto/seen-tags.txt): + 1. Añadir a seen-tags + 2. Disparar scout + + +SYSTEMD SERVICE (recomendado para uptime) +------------------------------------------ + /etc/systemd/system/oasis-webhook.service: + + [Unit] + Description=Oasis webhook receiver + After=network.target + + [Service] + User=oasis + WorkingDirectory=/opt/oasis-webhook + ExecStart=/usr/bin/python3 -m gunicorn -b 127.0.0.1:5000 app:app + Restart=always + + [Install] + WantedBy=multi-user.target + + +CADDY CONFIG (reverse proxy) +---------------------------- + /etc/caddy/Caddyfile: + + your-vps.com { + handle /api/oasis-release { + reverse_proxy 127.0.0.1:5000 + } + handle /testing-app/* { + root * /var/oasis-auto/apks + file_server browse + } + } + + +PROS +---- + - Reacción casi en tiempo real (segundos) + - No esperas al lunes + - Si combina con cron de respaldo, robusto + + +CONTRAS +------- + - Requiere VPS público con dominio + - Hay que gestionar HTTPS (Caddy/Let's Encrypt lo hace fácil) + - Si el webhook falla por red, hay que tener fallback + + +RECOMENDACIÓN: COMBINAR WEBHOOK + CRON DE RESPALDO +--------------------------------------------------- + Webhook dispara scout inmediatamente cuando hay release. + Cron semanal lunes 03:00 también dispara scout para no + perder oportunidades si el webhook falló. + + Scout debe ser idempotente: si ya procesó el commit X, + no debe procesarlo de nuevo. diff --git a/AUTOMATIZACION/06_TESTING_APP_seccion.txt b/AUTOMATIZACION/06_TESTING_APP_seccion.txt new file mode 100644 index 00000000..7f30cc0d --- /dev/null +++ b/AUTOMATIZACION/06_TESTING_APP_seccion.txt @@ -0,0 +1,147 @@ +=============================================================== + SECCIÓN /testing-app EN 0asis.net +=============================================================== + +OBJETIVO +-------- +Tener una URL pública (ej: 0asis.net/testing-app) donde +automáticamente se publican las APK beta generadas por la +automatización. Los usuarios beta-testers pueden descargar y +probar antes de que se libere oficialmente. + + +ESTRUCTURA RECOMENDADA +---------------------- + https://0asis.net/ -- web principal pública + https://0asis.net/testing-app/ -- index de beta APKs + https://0asis.net/testing-app/latest -- siempre la última + https://0asis.net/testing-app/archive -- histórico + + +IMPLEMENTACIÓN +-------------- + + OPCIÓN A: Carpeta estática servida por Nginx/Caddy + -------------------------------------------------- + Más simple. Cuando el builder termina de generar la APK: + + cp /tmp/oasis-aligned.apk /var/www/0asis.net/testing-app/ + oasis-v0.7.6-20260509-pruebas.apk + + ln -sf oasis-v0.7.6-20260509-pruebas.apk + /var/www/0asis.net/testing-app/latest.apk + + Caddyfile: + 0asis.net { + handle /testing-app/* { + root * /var/www/0asis.net + file_server browse + @latest path /testing-app/latest.apk + header @latest Content-Disposition "attachment; filename=oasis-latest-pruebas.apk" + } + handle { + # web principal + } + } + + + OPCIÓN B: Index dinámico con metadatos + -------------------------------------- + El index muestra una tabla con cada APK, fecha, changelog, + link de descarga. Más útil para usuarios. + + Generar /var/www/0asis.net/testing-app/index.html + automáticamente al final de builder.sh: + +

Oasis Mobile - Testing builds

+ + + + + + + + + ... +
VersiónFechaCambiosDownload
v0.7.62026-05-09Graphos, encriptación E2E, peers...APK
+ + + OPCIÓN C: Página dinámica con Caddy + tag de release + ---------------------------------------------------- + Más profesional: una página que lee el último JSON generado + por builder y renderiza UI bonita con changelog. + + +CHANGELOG AUTOMÁTICO +-------------------- +El scout puede generar un changelog.md amigable. Por ejemplo: + + ## v0.7.6 — 2026-05-09 (beta) + + ### Nuevas funcionalidades + - Graphos: visualización interactiva de la red + - AINav (deshabilitado en móvil, requiere LLM) + - Encriptación E2E para chats / calendarios / mapas + - Cascada al borrar tribes + + ### Bugfixes + - Resolución de carreras en updates concurrentes + + ### Cambios visuales + - Tabla de peers mejorada + - Dashboard de estadísticas + + [Descargar APK](oasis-v0.7.6-20260509-pruebas.apk) + +Este markdown puede renderizarse a HTML con `pandoc` o +incluirse tal cual en una página estática. + + +SEGURIDAD DE LA SECCIÓN /testing-app +------------------------------------ +- Considera proteger /testing-app con HTTP basic auth + para que solo beta-testers tengan acceso: + Caddyfile: + basicauth /testing-app/* { + tester $2a$14$ + } +- O usar tokens en URL: /testing-app/{token}/latest.apk +- La APK lleva tu firma — cualquiera con ella puede + instalar. Si te preocupa el leak, mantén privado. + + +INTEGRACIÓN CON TELEGRAM +------------------------ +Al final del builder, además de subir la APK al servidor: + + bash oasis-notify-telegram.sh \ + "Nueva APK lista para test: \ + https://0asis.net/testing-app/oasis-v0.7.6-20260509-pruebas.apk \ + [Cambios](https://0asis.net/testing-app/v0.7.6-changelog.html)" + +Los beta-testers en el canal de Telegram reciben el aviso al +instante y pueden instalar sin abrir email ni nada. + + +MIGRACIÓN A "RELEASE OFICIAL" +----------------------------- +Cuando una versión beta pasa el período de prueba (ej: 1 semana +sin reportes), tú manualmente la copias a /release/: + + cp /var/www/0asis.net/testing-app/oasis-v0.7.6-20260509-pruebas.apk + /var/www/0asis.net/release/oasis-v0.7.6.apk + + ln -sf oasis-v0.7.6.apk /var/www/0asis.net/release/latest.apk + +Y publicas en redes / Telegram canal release. + + +ARCHIVO DE BUILDS HISTÓRICAS +----------------------------- +Configura una limpieza para no llenar el disco: + + /etc/cron.daily/oasis-cleanup: + find /var/www/0asis.net/testing-app -name "oasis-*.apk" \ + -mtime +60 -delete + +Mantiene los últimos 60 días de builds beta. diff --git a/AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt b/AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt new file mode 100644 index 00000000..79532dda --- /dev/null +++ b/AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt @@ -0,0 +1,162 @@ +=============================================================== + HUMAN-IN-THE-LOOP — Lo que NO puede automatizarse 100% +=============================================================== + +POR QUÉ NO ES VIABLE EL FULL-AUTO +--------------------------------- +Hay archivos donde el upstream y nuestro fork divergen +intencionadamente. Aplicar automáticamente el upstream +ROMPERÍA nuestra app móvil. + +Estos archivos SIEMPRE requieren revisión humana antes de +aplicar cualquier cambio. + + +LISTA DE ARCHIVOS "PROHIBIDOS" PARA AUTO-MERGE +---------------------------------------------- + + src/client/assets/themes/OasisMobile.css + Razón: Es nuestro tema de móvil, escrito desde cero. + El upstream lo trata como tema de escritorio. + Aplicar el de upstream rompería: hamburger nav, QR + lightbox, panel-quicklinks, sidebar-panel drawer. + + src/client/assets/styles/mobile.css + Razón: 100% nuestro. Contiene el paginador actpager, + safe-area iOS, header móvil de 1-fila + 2-fila, todo + el sistema mobile. + + src/client/public/js/mobile-ui.js + Razón: Ya no se usa (paginador pasó a CSS-only) pero + si upstream añade un mobile-ui.js distinto, NO copiar. + + src/views/mobile_pager.js + Razón: Helper nuestro, no existe en upstream. + + src/views/main_views.js + Razón: Contiene nuestro menú hamburger CSS-only, + sidebar-panel drawer, panel-quicklinks. El upstream + no tiene esto. Aplicar pisaría todo nuestro layout. + + EXCEPCIÓN: Si solo cambian textos / strings i18n, + se puede hacer merge cuidadoso. Pero requiere mirada. + + src/views/inhabitants_view.js + Razón: Tiene nuestros QR codes integrados (qr-share + con
/). El upstream no. + Hay que merge manual preservando los QR. + + src/views/invites_view.js + Razón: Tiene nuestros QR por cada pub + IIFE async + + inviteLog renderInviteExtra. + + src/views/tribes_view.js + Razón: QR de tribe invite + nuestros campos del form + (isAnonymous, isLARP, isSubEdit). + + src/models/tribes_model.js + Razón: Tiene NUESTRO inviteLog (generateInvite + + joinByInvite). El upstream no. Si copiamos, perdemos + la trazabilidad de invites. + + src/models/main_models.js + Razón: Publica SSB msg type:'pub-invite' al aceptar + pub invite. Es nuestro. + + src/backend/backend.js + Razón: Tiene rutas custom (POST /settings/invite/accept, + nuestro orden de constructores). Cualquier cambio + upstream debe integrarse manualmente. + + src/configs/config-manager.js + Razón: Defaults modificados (wish, pmVisibility, + economy modules on, etc). + + src/server/package.json + Razón: NO añadir las deps nuevas del upstream: + - @xenova/transformers (60MB+) + - node-llama-cpp (no funciona arm64 mobile) + - pdfjs-dist (pendiente probar, anotar para luego) + + src/client/middleware.js + Razón: Tiene 'unsafe-inline' en CSP necesario para + nosotros + mounts /game-assets /maptiles /mapcache + custom + frame-src 'self'. + + +LISTA DE ARCHIVOS "SAFE" PARA AUTO-MERGE +---------------------------------------- + + src/client/assets/translations/*.js + Las traducciones son aditivas. Pueden copiarse al + 100% del upstream sin riesgo. + + src/client/assets/themes/Clear-SNH.css + src/client/assets/themes/Dark-SNH.css + src/client/assets/themes/Matrix-SNH.css + src/client/assets/themes/Purple-SNH.css + Temas de escritorio, no afectan a móvil. + + src/views/*_view.js (NUEVOS) + Si el archivo no existe en nuestro fork, copia auto. + Ej: graphos_view.js, markdown.js. + + src/views/peers_view.js + src/views/stats_view.js + src/views/courts_view.js + src/views/parliament_view.js + Vistas que no tienen nuestros customizations, safe + para refresh completo. + + src/models/*.js (NUEVOS) + Si es un modelo nuevo, copia auto. + + docs/CHANGELOG.md + Copia auto, solo es informativo. + + +SEÑALES DE ALARMA QUE EL AGENTE DEBE DETECTAR +--------------------------------------------- + +Si el scout detecta cualquiera de estos patrones en el +diff, debe marcar la sync entera como "needs human": + + - Cambios en mobile.css / OasisMobile.css del upstream + (no debería pasar, pero si pasa, alguien añadió tema + móvil al upstream y hay que decidir qué hacer) + + - Nuevas dependencias en server/package.json que no + están en una whitelist conocida + + - Cambios en CSP de middleware.js que reduzcan permisos + + - Cambios en SSB_server.js que afecten al modo public + de nuestro APK + + - Cambios en blobHandler.js (lo modificamos a veces) + + +DECISIÓN POR DEFECTO +-------------------- +Cuando el scout no puede decidir con seguridad: + + "Si tengo duda, marcar como REVIEW. Es preferible que + el humano apruebe cinco minutos a romper la APK." + + +PROCESO DE REVISIÓN HUMANA (5 min lunes mañana) +------------------------------------------------ +1. Abrir scout.md generado el lunes 03:00 +2. Lectura rápida de la lista SAFE (debe verse razonable) +3. Lectura cuidadosa de la lista REVIEW +4. Si todo OK: curl POST /api/approve o nada (auto-approve) +5. Si algo no OK: ejecutar manualmente claude con prompt + específico para ese conflicto +6. El builder corre solo a las 05:00 + + +REGLA DE ORO +------------ +NUNCA bajar AUTO_APPROVE_THRESHOLD por debajo de 0.5. +Mejor perder 5 min revisando que perder 2 horas +reconstruyendo la APK porque un cambio rompió algo. diff --git a/AUTOMATIZACION/08_PROMPTS_para_agentes.md b/AUTOMATIZACION/08_PROMPTS_para_agentes.md new file mode 100644 index 00000000..3c40d9a3 --- /dev/null +++ b/AUTOMATIZACION/08_PROMPTS_para_agentes.md @@ -0,0 +1,168 @@ +# Prompts para los agentes + +Copia/pega cada prompt al script correspondiente. + +## SCOUT (lunes 03:00) + +``` +Eres el agente SCOUT de Oasis Mobile auto-update. + +Contexto: +- Repo local: /opt/oasis_mobile +- Upstream: https://github.com/epsylon/oasis.git (remote `upstream`) +- Lee CONTEXT/cambio_apk_repack.txt y AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt + para entender qué archivos son prohibidos y cuáles safe. + +Tu tarea: +1. `git fetch upstream` +2. Identifica el último tag / commit del upstream/main. +3. Compara con nuestro HEAD: lista cada archivo cambiado. +4. Para cada archivo aplica las reglas de clasificación: + - SAFE = aplicar auto (translations, themes desktop, archivos + nuevos que no existen en local) + - REVIEW = requiere mirada humana (models, backend, views con + customizations nuestras) + - SKIP = nunca aplicar (mobile.css, OasisMobile.css, + server/package.json con deps prohibidas) +5. Para cada archivo SAFE y REVIEW, genera un resumen de 1-2 + líneas del cambio. +6. Escribe el reporte en formato JSON Y formato Markdown legible: + - /var/oasis-auto/reports/$(date +%Y-%m-%d)-scout.json + - /var/oasis-auto/reports/$(date +%Y-%m-%d)-scout.md +7. Calcula un "conflict_score" entre 0.0 (sin conflictos) y 1.0 + (muchos archivos REVIEW). Inclúyelo en el JSON. +8. NO hagas merge. Solo analiza. +9. Notifica Telegram con link al reporte. + +Tu output debe ser breve y estructurado. +Si encuentras algún archivo PROHIBIDO modificado en upstream +(ej: OasisMobile.css), márcalo como FATAL en el reporte y +recomienda no auto-aprobar. +``` + +## MERGER (lunes 04:00) + +``` +Eres el agente MERGER de Oasis Mobile auto-update. + +Contexto: +- Lee el último scout.json en /var/oasis-auto/reports/ +- Lee CONTEXT/cambio_apk_repack.txt +- Lee AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt + +Tu tarea: +1. Verifica que existe scout.json reciente (<24h). +2. Lee approval_status del reporte: + - "approved" o conflict_score < AUTO_APPROVE_THRESHOLD + → procede + - cualquier otra cosa → salir y notificar +3. Crea branch: `git checkout -b auto-merge-$(date +%Y%m%d)` +4. Para cada archivo "safe" del reporte: + `git checkout upstream/main -- $file` +5. Para cada archivo "review" del reporte: + - Generar diff + - Si el diff es pequeño (<30 líneas) y NO toca patrones + prohibidos (inviteLog, QR, hamburger, mobile-pager, + unsafe-inline): aplicar + - Si no: dejar el archivo intacto y marcar como pending +6. Para cada archivo "skip": ignorar +7. Verifica con `git status` que no hay archivos sin trackear + inesperados. +8. Verifica syntax con `node --check` en cada .js modificado. +9. Si todo OK: + `git commit -m "feat: merge upstream v$VERSION (auto)"` +10. Escribe reporte merger.md con qué archivos aplicaste. +11. Notifica Telegram. + +NUNCA modifiques estos archivos: +- src/client/assets/themes/OasisMobile.css +- src/client/assets/styles/mobile.css +- src/client/public/js/mobile-ui.js +- src/views/mobile_pager.js +- src/views/main_views.js (excepto i18n strings sin layout) + +Si tienes dudas en cualquier paso, NO procedas. Genera un +reporte explicando y termina. +``` + +## BUILDER (lunes 05:00) + +``` +Eres el agente BUILDER de Oasis Mobile auto-update. + +Contexto: +- Lee CONTEXT/cambio_apk_repack.txt para los pasos exactos + de build. +- Lee AUTOMATIZACION/06_TESTING_APP_seccion.txt para saber + dónde subir la APK. + +Tu tarea: +1. Verifica que estás en una branch auto-merge-*. +2. Verifica working tree limpio: `git status --porcelain` + debe estar vacío. +3. Verifica que no hay markers de merge sin resolver: + `grep -rn "<<<<<<< HEAD" src/` debe estar vacío. +4. Construye el zip del bundle: + ``` + cd nodejs-project/ + zip -0 -r /tmp/nodejs-project-new.zip nodejs-project/ \ + -x "*.apk" "*/.git/*" + ``` +5. Empaca + firma la APK siguiendo cambio_apk_repack.txt: + - cp oasis-v0.6.8.apk /tmp/oasis-temp.apk + - zip -d /tmp/oasis-temp.apk "META-INF/*" + - cd /tmp && zip -0 oasis-temp.apk assets/nodejs-project.zip + - zipalign + apksigner sign +6. Verifica la firma: `apksigner verify --verbose`. +7. Genera nombre con fecha: + `oasis-v$VERSION-$(date +%Y%m%d)-pruebas.apk` +8. Borra APKs de pruebas anteriores en /home/sito/ + (mantener la base oasis-v0.6.8.apk). +9. Copia la APK a /var/www/0asis.net/testing-app/ +10. Actualiza el symlink latest.apk. +11. Escribe builder.md con resumen del build. +12. Notifica Telegram con link de descarga. + +Si zipalign o apksigner fallan: NO subas la APK. Solo +notifica el fallo y deja el archivo en /tmp para debug. + +Si el build sale > 200MB (anormal): NO subas. Algo se metió +que no debería. Notifica. +``` + +## EJEMPLOS DE USO + +```bash +# Scout manual +claude -p "$(cat /opt/scripts/prompts/scout.md)" \ + --allowed-tools "Read,Write,Bash" \ + --working-dir /opt/oasis_mobile + +# Merger manual (después de revisar scout) +claude -p "$(cat /opt/scripts/prompts/merger.md)" \ + --allowed-tools "Read,Write,Edit,Bash" \ + --working-dir /opt/oasis_mobile + +# Builder manual +claude -p "$(cat /opt/scripts/prompts/builder.md)" \ + --allowed-tools "Read,Write,Bash" \ + --working-dir /opt/oasis_mobile +``` + +## TIPS PARA HACER LOS PROMPTS MÁS FIABLES + +1. **Prefijo de identidad**: "Eres el agente X" — fija el rol. +2. **Lista numerada**: las pasos en orden ayuda a no saltar. +3. **Reglas negativas explícitas**: "NUNCA modifiques X". +4. **Comprobaciones de éxito**: "Verifica con node --check". +5. **Fallback claro**: "Si no estás seguro, NO procedas". +6. **Output esperado**: "Escribe reporte en formato X". +7. **Notificación**: cada agente notifica al final. + +## TAMAÑO DE PROMPTS + +Los tres prompts juntos suman ~3000 tokens. +Con context (lectura de CONTEXT/*) + ejecución, cada agente +consume ~30-60k tokens en input + ~5-15k output. +Coste estimado total por sync: ~$0.5-1.50 USD. +``` diff --git a/AUTOMATIZACION/09_SCRIPTS.md b/AUTOMATIZACION/09_SCRIPTS.md new file mode 100644 index 00000000..7a3d6068 --- /dev/null +++ b/AUTOMATIZACION/09_SCRIPTS.md @@ -0,0 +1,295 @@ +# Scripts bash listos para usar + +Copia a `/opt/scripts/` en el VPS Debian. + +## `oasis-scout.sh` + +```bash +#!/bin/bash +set -euo pipefail +REPO=/opt/oasis_mobile +REPORTS=/var/oasis-auto/reports +mkdir -p "$REPORTS" + +cd "$REPO" +git fetch upstream 2>&1 +DATE=$(date +%Y-%m-%d) + +claude -p "$(cat /opt/scripts/prompts/scout.md)" \ + --allowed-tools "Read,Write,Bash" \ + > "$REPORTS/$DATE-scout-claude.log" 2>&1 + +# El reporte JSON y MD se generan por claude +if [ -f "$REPORTS/$DATE-scout.json" ]; then + bash /opt/scripts/notify-telegram.sh "📊 Scout listo para $DATE. Ver: https://0asis.net/admin/reports/$DATE" +else + bash /opt/scripts/notify-telegram.sh "❌ Scout falló para $DATE. Log: $REPORTS/$DATE-scout-claude.log" + exit 1 +fi +``` + +## `oasis-merger.sh` + +```bash +#!/bin/bash +set -euo pipefail +REPO=/opt/oasis_mobile +REPORTS=/var/oasis-auto/reports +DATE=$(date +%Y-%m-%d) +THRESHOLD=${AUTO_APPROVE_THRESHOLD:-1.0} + +cd "$REPO" + +# Verificar que existe scout reciente +if [ ! -f "$REPORTS/$DATE-scout.json" ]; then + bash /opt/scripts/notify-telegram.sh "⚠️ Merger: no hay scout.json para $DATE. Saltando." + exit 0 +fi + +# Leer conflict_score +SCORE=$(jq -r '.conflict_score' "$REPORTS/$DATE-scout.json") +APPROVED=$(jq -r '.approved // false' "$REPORTS/$DATE-scout.json") + +if [ "$APPROVED" != "true" ] && (( $(echo "$SCORE > $THRESHOLD" | bc -l) )); then + bash /opt/scripts/notify-telegram.sh "⏸ Merger: conflict_score $SCORE > $THRESHOLD, esperando aprobación manual." + exit 0 +fi + +# Procede con merge +claude -p "$(cat /opt/scripts/prompts/merger.md)" \ + --allowed-tools "Read,Write,Edit,Bash" \ + > "$REPORTS/$DATE-merger-claude.log" 2>&1 + +if [ $? -eq 0 ]; then + bash /opt/scripts/notify-telegram.sh "✅ Merger OK para $DATE. Lista para build." +else + bash /opt/scripts/notify-telegram.sh "❌ Merger falló para $DATE." +fi +``` + +## `oasis-builder.sh` + +```bash +#!/bin/bash +set -euo pipefail +REPO=/opt/oasis_mobile +APKS=/var/www/0asis.net/testing-app +DATE=$(date +%Y%m%d) +mkdir -p "$APKS" + +cd "$REPO" + +# Verificar working tree limpio +if [ -n "$(git status --porcelain)" ]; then + bash /opt/scripts/notify-telegram.sh "⚠️ Builder: working tree no limpio. Saltando." + exit 1 +fi + +# Verificar no hay markers de conflicto +if grep -rn "<<<<<<< HEAD" "$REPO/nodejs-project/" 2>/dev/null; then + bash /opt/scripts/notify-telegram.sh "❌ Builder: hay conflictos sin resolver." + exit 1 +fi + +# Build zip +cd "$REPO/nodejs-project" +zip -0 -r /tmp/nodejs-project-new.zip nodejs-project/ \ + -x "*.apk" "*/.git/*" > /tmp/zip.log 2>&1 + +# Build APK +mkdir -p /tmp/assets +cp /tmp/nodejs-project-new.zip /tmp/assets/nodejs-project.zip +cp /opt/oasis-base/oasis-v0.6.8.apk /tmp/oasis-temp.apk +zip -d /tmp/oasis-temp.apk "META-INF/*" >/dev/null +cd /tmp && zip -0 oasis-temp.apk assets/nodejs-project.zip >/dev/null + +SDK=/opt/android-sdk/build-tools/35.0.1 +$SDK/zipalign -f -p 4 /tmp/oasis-temp.apk /tmp/oasis-aligned.apk +$SDK/apksigner sign \ + --ks /opt/secrets/oasis-release-key.jks \ + --ks-pass pass:oasis123 \ + --key-pass pass:oasis123 \ + --ks-key-alias oasis \ + /tmp/oasis-aligned.apk + +$SDK/apksigner verify /tmp/oasis-aligned.apk || { + bash /opt/scripts/notify-telegram.sh "❌ APK signature inválida." + exit 1 +} + +# Detectar versión del package.json +VERSION=$(jq -r '.version' "$REPO/nodejs-project/nodejs-project/src/server/package.json") + +# Borrar pruebas anteriores y copiar +rm -f "$APKS/oasis-*-pruebas.apk" +NAME="oasis-v$VERSION-$DATE-pruebas.apk" +cp /tmp/oasis-aligned.apk "$APKS/$NAME" +ln -sf "$NAME" "$APKS/latest.apk" + +SIZE=$(du -h "$APKS/$NAME" | cut -f1) +bash /opt/scripts/notify-telegram.sh "📱 APK $NAME ($SIZE) lista: https://0asis.net/testing-app/$NAME" +``` + +## `notify-telegram.sh` + +```bash +#!/bin/bash +TOKEN="${TELEGRAM_BOT_TOKEN:-}" +CHAT="${TELEGRAM_CHAT_ID:-}" +MSG="${1:-empty}" + +if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then + echo "Telegram no configurado, mensaje:" + echo "$MSG" + exit 0 +fi + +curl -s -X POST "https://api.telegram.org/bot$TOKEN/sendMessage" \ + -d "chat_id=$CHAT" \ + -d "text=$MSG" \ + -d "parse_mode=Markdown" > /dev/null +``` + +## `/etc/cron.d/oasis-auto` + +```cron +# Oasis Mobile auto-update pipeline +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxx +TELEGRAM_BOT_TOKEN=123456789:AAxxxx +TELEGRAM_CHAT_ID=-1001234567890 +AUTO_APPROVE_THRESHOLD=0.5 + +0 3 * * 1 oasis /opt/scripts/oasis-scout.sh +0 4 * * 1 oasis /opt/scripts/oasis-merger.sh +0 5 * * 1 oasis /opt/scripts/oasis-builder.sh +``` + +## Webhook receiver (Python Flask) + +```python +# /opt/oasis-webhook/app.py +from flask import Flask, request, abort +import hmac, hashlib, subprocess, os + +app = Flask(__name__) +SECRET = os.environ['WEBHOOK_SECRET'].encode() + +@app.post("/api/oasis-release") +def release(): + sig = request.headers.get("X-Hub-Signature-256", "") + mac = hmac.new(SECRET, request.data, hashlib.sha256) + expected = "sha256=" + mac.hexdigest() + if not hmac.compare_digest(sig, expected): + abort(401) + + event = request.headers.get("X-GitHub-Event") + if event != "release": + return "skip", 200 + + payload = request.json + if payload.get("action") == "published": + tag = payload["release"]["tag_name"] + subprocess.Popen([ + "sudo", "-u", "oasis", + "/opt/scripts/oasis-scout.sh", + "--tag", tag, "--reason", "webhook" + ]) + + return "ok", 200 +``` + +## `/etc/systemd/system/oasis-webhook.service` + +```ini +[Unit] +Description=Oasis webhook receiver +After=network.target + +[Service] +User=oasis +WorkingDirectory=/opt/oasis-webhook +EnvironmentFile=/opt/secrets/webhook.env +ExecStart=/usr/bin/python3 -m gunicorn -b 127.0.0.1:5000 app:app +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +## `/etc/caddy/Caddyfile` + +``` +0asis.net { + encode gzip zstd + + handle /api/oasis-release { + reverse_proxy 127.0.0.1:5000 + } + + handle_path /testing-app/* { + root * /var/www/0asis.net/testing-app + file_server browse + @apk path *.apk + header @apk Content-Disposition "attachment" + } + + handle { + root * /var/www/0asis.net + file_server + try_files {path} {path}/ /index.html + } +} +``` + +## Setup inicial — `bootstrap.sh` + +```bash +#!/bin/bash +# Ejecutar UNA vez al preparar el VPS Debian +set -euo pipefail + +# 1. User dedicado +useradd -m -s /bin/bash oasis || true + +# 2. Directorios +mkdir -p /opt/oasis_mobile /opt/scripts /opt/secrets /opt/oasis-base +mkdir -p /var/oasis-auto/reports /var/www/0asis.net/testing-app + +# 3. Dependencias del sistema +apt update +apt install -y git curl jq bc python3-pip nginx-light \ + openjdk-17-jdk-headless zip unzip cron + +# 4. Caddy +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/setup.deb.sh' | bash +apt install -y caddy + +# 5. Android SDK build-tools +mkdir -p /opt/android-sdk +cd /opt/android-sdk +curl -L -o cmdline-tools.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" +unzip cmdline-tools.zip +cmdline-tools/bin/sdkmanager --sdk_root=/opt/android-sdk \ + "build-tools;35.0.1" + +# 6. Claude Code +curl -fsSL https://claude.ai/install.sh | bash + +# 7. Permisos +chown -R oasis:oasis /opt/oasis_mobile /var/oasis-auto /opt/scripts + +# 8. Clonar repo (necesita SSH key configurada) +sudo -u oasis git clone git@code.03c8.net:s1to/oasis_mobile.git /opt/oasis_mobile +cd /opt/oasis_mobile +sudo -u oasis git remote add upstream https://github.com/epsylon/oasis.git + +echo "Bootstrap completo. Configura:" +echo " - /opt/secrets/oasis-release-key.jks (keystore)" +echo " - /etc/cron.d/oasis-auto (con tu ANTHROPIC_API_KEY)" +echo " - DNS de 0asis.net apuntando a este server" +echo " - /etc/caddy/Caddyfile" +echo " - systemctl enable --now oasis-webhook" +``` diff --git a/AUTOMATIZACION/10_DEBIAN_setup.txt b/AUTOMATIZACION/10_DEBIAN_setup.txt new file mode 100644 index 00000000..ccbf373e --- /dev/null +++ b/AUTOMATIZACION/10_DEBIAN_setup.txt @@ -0,0 +1,258 @@ +=============================================================== + DEBIAN VPS — Setup paso a paso +=============================================================== + +REQUISITOS MÍNIMOS DEL VPS +------------------------- + CPU: 2 cores + RAM: 4 GB (mínimo, recomendado 8 GB para AI tasks) + Disco: 30 GB (10 para sistema, 5 para Android SDK, + 10 para histórico de APKs) + OS: Debian 12 (bookworm) + Red: IP pública con dominio (si vas a usar webhooks) + + +PASO 1 — USUARIO Y DIRECTORIOS +------------------------------ + sudo adduser --system --group --home /home/oasis oasis + sudo mkdir -p /opt/oasis_mobile /opt/scripts /opt/secrets \ + /opt/oasis-base /opt/oasis-webhook \ + /var/oasis-auto/reports /var/oasis-auto/apks \ + /var/www/0asis.net/testing-app + sudo chown -R oasis:oasis /opt/oasis_mobile /opt/scripts \ + /var/oasis-auto /var/www/0asis.net + + +PASO 2 — DEPENDENCIAS BASE +-------------------------- + sudo apt update + sudo apt install -y \ + git curl wget jq bc \ + python3 python3-pip python3-flask python3-gunicorn \ + openjdk-17-jdk-headless \ + zip unzip cron \ + build-essential + + +PASO 3 — CADDY (REVERSE PROXY) +------------------------------ + sudo apt install -y debian-keyring debian-archive-keyring \ + apt-transport-https + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \ + sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \ + sudo tee /etc/apt/sources.list.d/caddy-stable.list + sudo apt update + sudo apt install -y caddy + sudo systemctl enable caddy + + +PASO 4 — ANDROID SDK BUILD-TOOLS +-------------------------------- + cd /tmp + wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + sudo mkdir -p /opt/android-sdk/cmdline-tools + sudo unzip -q commandlinetools-linux-*.zip -d /opt/android-sdk/cmdline-tools + sudo mv /opt/android-sdk/cmdline-tools/cmdline-tools \ + /opt/android-sdk/cmdline-tools/latest + + sudo yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses + sudo /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \ + "build-tools;35.0.1" + + # Verificar + /opt/android-sdk/build-tools/35.0.1/apksigner version + + +PASO 5 — CLAUDE CODE CLI +------------------------ + # Como usuario oasis (no root): + sudo -u oasis -i + curl -fsSL https://claude.ai/install.sh | bash + + # Loguearse o usar API key (recomendado para automatización): + export ANTHROPIC_API_KEY="sk-ant-xxxxx" + + # Test + claude -p "say hello" --allowed-tools "" \ + --output-format text + + exit # vuelve a root + + +PASO 6 — REPO LOCAL +------------------- + sudo -u oasis git clone /opt/oasis_mobile + cd /opt/oasis_mobile + sudo -u oasis git remote add upstream https://github.com/epsylon/oasis.git + sudo -u oasis git fetch upstream + + # Verificar + sudo -u oasis git log --oneline upstream/main | head -5 + + +PASO 7 — KEYSTORE +----------------- + # COPIAR el keystore desde tu máquina: + scp ~/oasis-release-key.jks \ + oasis@your-vps:/opt/secrets/oasis-release-key.jks + + # Permisos restrictivos + sudo chmod 600 /opt/secrets/oasis-release-key.jks + sudo chown oasis:oasis /opt/secrets/oasis-release-key.jks + + # Copia también el APK base + scp ~/oasis-v0.6.8.apk \ + oasis@your-vps:/opt/oasis-base/oasis-v0.6.8.apk + + +PASO 8 — SCRIPTS Y PROMPTS +-------------------------- + # Copia los scripts de AUTOMATIZACION/09_SCRIPTS.md a: + /opt/scripts/oasis-scout.sh + /opt/scripts/oasis-merger.sh + /opt/scripts/oasis-builder.sh + /opt/scripts/notify-telegram.sh + + # Copia los prompts de AUTOMATIZACION/08_PROMPTS_para_agentes.md a: + /opt/scripts/prompts/scout.md + /opt/scripts/prompts/merger.md + /opt/scripts/prompts/builder.md + + # Permisos + sudo chmod +x /opt/scripts/*.sh + sudo chown -R oasis:oasis /opt/scripts + + +PASO 9 — TELEGRAM BOT (opcional) +------------------------------- + # En Telegram: hablar con @BotFather, crear bot, copiar token. + # Añadir el bot a un canal/grupo donde recibir notificaciones. + # Obtener chat_id con: + curl "https://api.telegram.org/bot$TOKEN/getUpdates" + + # Guardar en /opt/secrets/telegram.env: + TELEGRAM_BOT_TOKEN=123456789:AAxxxx + TELEGRAM_CHAT_ID=-1001234567890 + + sudo chmod 600 /opt/secrets/telegram.env + + +PASO 10 — CRON +-------------- + sudo cp /opt/scripts/oasis-auto.cron /etc/cron.d/oasis-auto + + # Verificar + systemctl restart cron + cat /etc/cron.d/oasis-auto + + +PASO 11 — WEBHOOK (opcional, para opción D) +------------------------------------------- + cd /opt/oasis-webhook + # Copia app.py de 09_SCRIPTS.md + + # Generar secret + python3 -c "import secrets; print(secrets.token_hex(32))" \ + | sudo tee /opt/secrets/webhook-secret + + # Service + sudo cp oasis-webhook.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now oasis-webhook + + # Verificar + curl http://127.0.0.1:5000/api/health + + +PASO 12 — DNS Y HTTPS +--------------------- + # En tu DNS provider: + A 0asis.net -> IP-DEL-VPS + + # Caddyfile: + sudo cp Caddyfile /etc/caddy/Caddyfile + sudo systemctl reload caddy + + # Caddy obtiene certificado Let's Encrypt automáticamente. + # Verificar: + curl -I https://0asis.net + + +PASO 13 — WEBHOOK EN GITHUB +--------------------------- + # En el fork de oasis_mobile en GitHub: + Settings → Webhooks → Add webhook + URL: https://0asis.net/api/oasis-release + Secret: el de /opt/secrets/webhook-secret + Events: only "Releases" + + +PASO 14 — TEST INICIAL +---------------------- + # Como oasis user: + sudo -u oasis -i + + # Forzar scout manualmente + /opt/scripts/oasis-scout.sh + + # Verificar reporte + ls /var/oasis-auto/reports/ + + # Si OK, esperar al lunes o forzar pipeline completo + /opt/scripts/oasis-merger.sh && /opt/scripts/oasis-builder.sh + + +CHECKLIST FINAL +--------------- + [ ] User oasis creado + [ ] /opt/oasis_mobile con remote upstream + [ ] /opt/secrets con keystore y permisos 600 + [ ] /opt/oasis-base con oasis-v0.6.8.apk + [ ] Android SDK build-tools instalado + [ ] Claude Code instalado con API key + [ ] Scripts en /opt/scripts/ con permisos + [ ] Cron /etc/cron.d/oasis-auto configurado + [ ] Telegram bot (opcional) + [ ] Caddy + HTTPS para 0asis.net + [ ] Webhook receiver en systemd (opcional) + [ ] Webhook en GitHub configurado (opcional) + [ ] Test inicial OK + + +MONITORING +---------- + # Logs cron + tail -f /var/log/syslog | grep CRON + + # Logs de cada agente + tail -f /var/oasis-auto/reports/*.log + + # Logs Caddy + journalctl -u caddy -f + + # Logs webhook + journalctl -u oasis-webhook -f + + # Espacio en disco + df -h /var/www /opt /var/oasis-auto + + +PROBLEMAS COMUNES +----------------- + + "claude command not found": + El PATH del cron no incluye ~/.local/bin + → en cron usa /home/oasis/.local/bin/claude full path + + "git push falla": + El user oasis necesita SSH key configurada + → ssh-keygen -t ed25519 y añadir a tu Gitea/GitHub + + "apksigner: java not found": + sudo apt install openjdk-17-jdk-headless + Verifica: java --version + + "permission denied en keystore": + sudo chown oasis:oasis /opt/secrets/oasis-release-key.jks + sudo chmod 600 ... diff --git a/CONTEXT/00_INDICE.txt b/CONTEXT/00_INDICE.txt index 5362181f..89d82615 100644 --- a/CONTEXT/00_INDICE.txt +++ b/CONTEXT/00_INDICE.txt @@ -75,6 +75,20 @@ FICHEROS DE CAMBIOS (documentan lo que se modifico y por que) - APK actual: /home/sito/oasis-v0.7.4-20260502-pruebas.apk (132 MB) - Tabla de desglose de peso incluida +-------------------------------------------------------------- +TAREAS PENDIENTES +-------------------------------------------------------------- + + tareas_usabilidad.txt + Tareas de UX/usabilidad pendientes de implementar. + - Tarea 1: aplicar paginador CSS-only ‹ › al resto de submenús + (inventario completo: 8 con paginador, 24 pendientes, + 5 que no lo necesitan). + - Tarea 2: revisar mode-buttons-cols/row en forum/feed. + - Tarea 3: uniformar header en vistas de detalle. + - Tarea 4: revisar formularios largos en móvil. + - Tarea 5: considerar punto de quiebre 600px para tablet. + -------------------------------------------------------------- NOTA PARA EL DEVELOPER -------------------------------------------------------------- diff --git a/nodejs-project/nodejs-project/src/backend/backend.js b/nodejs-project/nodejs-project/src/backend/backend.js index 3fcd8b4d..b59ebca1 100644 --- a/nodejs-project/nodejs-project/src/backend/backend.js +++ b/nodejs-project/nodejs-project/src/backend/backend.js @@ -27,13 +27,13 @@ const { spawn } = require('child_process'); let fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer; try { ({ fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer } = require('../AI/buildAIContext.js')); -} catch (_) { +} catch (e) { const noop = () => {}; - fieldsForSnippet = () => []; + fieldsForSnippet = noop; buildContext = noop; - clip = (s) => s; + clip = (t) => t; publishExchange = noop; - getBestTrainedAnswer = noop; + getBestTrainedAnswer = () => null; } let aiStarted = false; function startAI() { @@ -42,7 +42,7 @@ function startAI() { try { const aiProcess = spawn('node', [path.resolve(__dirname, '../AI/ai_service.mjs')], { detached: true, stdio: 'ignore' }); aiProcess.unref(); - } catch (_) {} + } catch (e) {} } const ADDR_PATH = path.join(__dirname, '..', 'configs', 'wallet-addresses.json'); const readAddrMap = () => { try { return JSON.parse(fs.readFileSync(ADDR_PATH, 'utf8')); } catch { return {}; } }; @@ -61,7 +61,7 @@ const ensureTerm = async () => { let sweepInFlight = null; const runSweepOnce = async () => { if (sweepInFlight) return sweepInFlight; - sweepInFlight = parliamentModel.sweepProposals().catch(() => {}).finally(() => { sweepInFlight = null; }); + sweepInFlight = parliamentModel.sweepProposals().catch(e => console.error('sweepProposals failed:', e)).finally(() => { sweepInFlight = null; }); return sweepInFlight; }; @@ -130,6 +130,37 @@ const sanitizeMsgText = (msg) => { const sanitizeMessages = (msgs) => Array.isArray(msgs) ? msgs.map(sanitizeMsgText) : msgs; const parseBool01 = v => String(Array.isArray(v) ? v[v.length - 1] : v || '') === '1'; +const sendErrorPage = (ctx, message, { title, status } = {}) => { + const { errorView } = require('../views/main_views'); + const ref = ctx.request.header.referer; + let backHref = '/'; + try { + if (ref) { + const u = new URL(ref); + if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) { + backHref = u.pathname + u.search + u.hash; + } + } + } catch (_) {} + if (status) ctx.status = status; + ctx.type = 'html'; + ctx.body = errorView({ title, message, backHref }); +}; + +const safeRefererRedirect = (ctx, fallback = '/') => { + const ref = ctx.request.header.referer; + if (!ref) { ctx.redirect(fallback); return; } + try { + const u = new URL(ref); + if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== ctx.host) { + ctx.redirect(fallback); + return; + } + ctx.redirect(u.pathname + u.search + u.hash); + } catch (_) { + ctx.redirect(fallback); + } +}; const checkMod = (ctx, mod) => { const cfg = getConfig(); const serverValue = cfg.modules?.[mod]; @@ -139,101 +170,18 @@ const checkMod = (ctx, mod) => { return serverValue === 'on' || serverValue === undefined; }; const getViewerId = () => SSBconfig?.config?.keys?.id || SSBconfig?.keys?.id; -const refreshInboxCount = async (messagesOpt) => { - const messages = messagesOpt || await pmModel.listAllPrivate(); - const userId = getViewerId(); - const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId); - const filtered = messages.filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true); - sharedState.setInboxCount(filtered.filter(isToUser).length); -}; const getUserTribeIds = async (uid) => { const allTribes = await tribesModel.listAll().catch(() => []); const memberTribes = allTribes.filter(t => t.members.includes(uid)); const idSets = await Promise.all(memberTribes.map(t => tribesModel.getChainIds(t.id).catch(() => [t.id]))); return new Set(idSets.flat()); }; -const makeCtxMutualCache = () => { - const cache = new Map(); - const frictionActive = viewerFilters.isFrictionActive(); - return async (otherId) => { - if (!otherId) return false; - if (cache.has(otherId)) return cache.get(otherId); - let rel; - try { rel = await friend.getRelationship(otherId); } catch (e) { rel = null; } - const basic = !!(rel && rel.following && rel.followsMe); - const mutual = frictionActive ? (basic && viewerFilters.isAccepted(otherId)) : basic; - cache.set(otherId, mutual); - return mutual; - }; -}; -const extractItemAuthor = (item) => { - if (!item) return null; - if (typeof item === 'string') return null; - if (item.value && item.value.author) return item.value.author; - if (item.author) return item.author; - if (item.feed) return item.feed; - if (item.organizer) return item.organizer; - if (item.proposer) return item.proposer; - if (item.owner) return item.owner; - if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id; - return null; -}; -const extractItemTribeId = (item) => { - if (!item || typeof item !== 'object') return null; - if (item.tribeId) return item.tribeId; - if (item.value && item.value.content && item.value.content.tribeId) return item.value.content.tribeId; - if (item.content && item.content.tribeId) return item.content.tribeId; - return null; -}; -const getViewerTribeAccessSets = async (userId) => { - if (!userId) return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() }; - try { - const all = await tribesModel.listAll(); - const memberOf = new Set(); - const createdBy = new Set(); - const privateNotAccessible = new Set(); - for (const t of all) { - const isMember = Array.isArray(t.members) && t.members.includes(userId); - const isCreator = t.author === userId; - if (isCreator) { createdBy.add(t.id); memberOf.add(t.id); } - else if (isMember) memberOf.add(t.id); - const ancestryPrivate = await (async () => { - try { const eff = await tribesModel.getEffectiveStatus(t.id); return eff.isPrivate; } catch (e) { return !!t.isAnonymous; } - })(); - if (ancestryPrivate && !isMember && !isCreator) privateNotAccessible.add(t.id); - } - return { memberOf, createdBy, privateNotAccessible }; - } catch (e) { - return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() }; - } -}; -const applyListFilters = async (items, ctx, opts = {}) => { - if (!Array.isArray(items)) return items; - const cfg = getConfig(); - const viewer = getViewerId(); - const wishMutuals = cfg.wish === 'mutuals'; - let out = items; - if (!opts.skipTribeAccess) { - const { memberOf, createdBy, privateNotAccessible } = await getViewerTribeAccessSets(viewer); - out = out.filter(it => { - const tid = extractItemTribeId(it); - if (!tid) return true; - if (memberOf.has(tid) || createdBy.has(tid)) return true; - if (privateNotAccessible.has(tid)) return false; - return true; - }); - } - if (wishMutuals && !opts.skipMutual) { - const isMutual = makeCtxMutualCache(); - const filtered = []; - for (const it of out) { - const a = extractItemAuthor(it); - if (!a || a === viewer) { filtered.push(it); continue; } - if (await isMutual(a)) filtered.push(it); - } - out = filtered; - } - return out; +const refreshInboxCount = async (messagesOpt) => { + const messages = messagesOpt || await pmModel.listAllPrivate(); + const userId = getViewerId(); + const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId); + const filtered = messages.filter(m => m && m.key && m.value && m.value.content && m.value.content.type === 'post' && m.value.content.private === true); + sharedState.setInboxCount(filtered.filter(isToUser).length); }; const mediaFavorites = require("./media-favorites.js"); const customStyleFile = path.join(envPaths("oasis", { suffix: "" }).config, "/custom-style.css"); @@ -291,6 +239,11 @@ Alternatively, you can set the default port in ${defaultConfigFile} with: } }); }); + } else if (err && (err.name === 'OpenError' || (typeof err.message === 'string' && /Resource temporarily unavailable/i.test(err.message) && /\.ssb\/.*LOCK/i.test(err.message)))) { + console.log(""); + console.log("Another Oasis instance is already running on this machine. Close the other instance (or kill the process) and try again."); + console.log(""); + process.exit(1); } else { console.log(""); console.log("Oasis traceback (share below content with devs to report!):"); @@ -322,7 +275,7 @@ const extractMentions = async (text) => { })); return resolvedMentions; }; -const cooler = ssb({ offline: config.offline }); +const cooler = ssb({ offline: config.offline, port: config.port, host: config.host, isPublic: config.public }); const models = require("../models/main_models"); const { about, blob, friend, meta, post, vote } = models({ cooler, @@ -330,8 +283,6 @@ const { about, blob, friend, meta, post, vote } = models({ }); const { handleBlobUpload, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js'); const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null; -const ssbConfig = require('../server/ssb_config'); -const tribeCrypto = require('../models/tribe_crypto')(ssbConfig.path); const exportmodeModel = require('../models/exportmode_model'); const panicmodeModel = require('../models/panicmode_model'); const cipherModel = require('../models/cipher_model'); @@ -343,23 +294,27 @@ const opinionsModel = require('../models/opinions_model')({ cooler, isPublic: co const eventsModel = require('../models/events_model')({ cooler, isPublic: config.public }); const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.public }); const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public }); +const ssbConfig = require('../server/ssb_config'); +const tribeCrypto = require('../models/tribe_crypto')(ssbConfig.path); +const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto }); const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public }); const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public }); +const calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, tribesModel }); const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public }); const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public }); const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public }); const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public }); const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public }); +const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public }); const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public }); const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public }); const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public }); const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public }); -const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto }); const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, tribesModel }); const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); -const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel }); +const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public }); const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public }); const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto }); @@ -368,17 +323,182 @@ const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto }); const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto }); const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, tribesModel }); -const calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, tribesModel }); -const torrentsModel = require("../models/torrents_model")({ cooler, tribeCrypto, tribesModel }); const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public }); -const mapsModel = require("../models/maps_model")({ cooler, tribeCrypto, tribesModel }); +const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const gamesModel = require('../models/games_model')({ cooler }); const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public }); -const logsModel = require("../models/logs_model")({ cooler }); const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel }); +const logsModel = require("../models/logs_model")({ cooler }); const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } }); -const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto }); +const { renderGovernance: renderTribeGovernance } = require('../views/tribes_view'); const viewerFilters = require('../models/viewer_filters'); + +const scanPendingFollows = async (viewerId) => { + if (!viewerId) return; + if (!viewerFilters.isFrictionActive()) return; + const pullStream = require('../server/node_modules/pull-stream'); + const ssbClient = await cooler.open(); + const limit = getConfig().ssbLogStream?.limit || 1000; + const rows = await new Promise((res, rej) => { + pullStream( + ssbClient.createLogStream({ reverse: true, limit }), + pullStream.collect((err, arr) => err ? rej(err) : res(arr || [])) + ); + }); + const accepted = new Set(viewerFilters.loadAccepted()); + const pendingIds = new Set(viewerFilters.listPending().map(x => x.followerId)); + for (const msg of rows) { + const c = msg.value?.content; + if (!c || c.type !== 'contact') continue; + if (c.contact !== viewerId) continue; + if (c.following !== true) continue; + const author = msg.value?.author; + if (!author || author === viewerId) continue; + if (accepted.has(author)) continue; + if (pendingIds.has(author)) continue; + viewerFilters.enqueuePending(author); + pendingIds.add(author); + } +}; + +const { section: hSection } = require('../server/node_modules/hyperaxe'); + +const renderPendingFollows = (items) => { + const { template: tpl, i18n: i18nLocal } = require('../views/main_views'); + const { div, h2, p, form, button, input, ul, li, span, a } = require('../server/node_modules/hyperaxe'); + return tpl( + i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests', + hSection( + div({ class: 'tags-header' }, + h2(i18nLocal.inhabitantsPendingFollowsTitle || 'Pending follow requests'), + p(i18nLocal.pmMutualNotice || '') + ), + (!Array.isArray(items) || items.length === 0) + ? p('—') + : ul({}, items.map(it => + li({}, + span({ style: 'font-weight:bold' }, it.name || it.followerId), + ' — ', + span({ class: 'muted' }, it.followerId.slice(0, 14) + '…'), + ' ', + form({ method: 'POST', action: '/inhabitants/follow/accept', style: 'display:inline' }, + input({ type: 'hidden', name: 'followerId', value: it.followerId }), + button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingAccept || 'Accept') + ), + ' ', + form({ method: 'POST', action: '/inhabitants/follow/reject', style: 'display:inline' }, + input({ type: 'hidden', name: 'followerId', value: it.followerId }), + button({ type: 'submit', class: 'filter-btn' }, i18nLocal.inhabitantsPendingReject || 'Reject') + ) + ) + )) + ) + ); +}; + +const makeCtxMutualCache = () => { + const cache = new Map(); + const frictionActive = viewerFilters.isFrictionActive(); + return async (otherId) => { + if (!otherId) return false; + if (cache.has(otherId)) return cache.get(otherId); + let rel; + try { rel = await friend.getRelationship(otherId); } catch (e) { rel = null; } + const basic = !!(rel && rel.following && rel.followsMe); + const mutual = frictionActive ? (basic && viewerFilters.isAccepted(otherId)) : basic; + cache.set(otherId, mutual); + return mutual; + }; +}; + +const extractItemAuthor = (item) => { + if (!item) return null; + if (typeof item === 'string') return null; + if (item.value && item.value.author) return item.value.author; + if (item.author) return item.author; + if (item.feed) return item.feed; + if (item.organizer) return item.organizer; + if (item.proposer) return item.proposer; + if (item.owner) return item.owner; + if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id; + return null; +}; + +const extractItemTribeId = (item) => { + if (!item || typeof item !== 'object') return null; + if (item.tribeId) return item.tribeId; + if (item.value && item.value.content && item.value.content.tribeId) return item.value.content.tribeId; + if (item.content && item.content.tribeId) return item.content.tribeId; + return null; +}; + +const getViewerTribeAccessSets = async (userId) => { + if (!userId) return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() }; + try { + const all = await tribesModel.listAll(); + const memberOf = new Set(); + const createdBy = new Set(); + const privateNotAccessible = new Set(); + for (const t of all) { + const isMember = Array.isArray(t.members) && t.members.includes(userId); + const isCreator = t.author === userId; + if (isCreator) { createdBy.add(t.id); memberOf.add(t.id); } + else if (isMember) memberOf.add(t.id); + const ancestryPrivate = await (async () => { + try { const eff = await tribesModel.getEffectiveStatus(t.id); return eff.isPrivate; } catch (e) { return !!t.isAnonymous; } + })(); + if (ancestryPrivate && !isMember && !isCreator) privateNotAccessible.add(t.id); + } + return { memberOf, createdBy, privateNotAccessible }; + } catch (e) { + return { memberOf: new Set(), createdBy: new Set(), privateNotAccessible: new Set() }; + } +}; + +const applyListFilters = async (items, ctx, opts = {}) => { + if (!Array.isArray(items)) return items; + const cfg = getConfig(); + const viewer = getViewerId(); + const wishMutuals = cfg.wish === 'mutuals'; + let out = items; + if (!opts.skipTribeAccess) { + const { memberOf, createdBy, privateNotAccessible } = await getViewerTribeAccessSets(viewer); + out = out.filter(it => { + const tid = extractItemTribeId(it); + if (!tid) return true; + if (memberOf.has(tid) || createdBy.has(tid)) return true; + if (privateNotAccessible.has(tid)) return false; + return true; + }); + } + if (wishMutuals && !opts.skipMutual) { + const isMutual = makeCtxMutualCache(); + const filtered = []; + for (const it of out) { + const a = extractItemAuthor(it); + if (!a || a === viewer) { filtered.push(it); continue; } + if (await isMutual(a)) filtered.push(it); + } + out = filtered; + } + return out; +}; +const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto }); +tribesModel.processIncomingKeys().then(async () => { + try { + const viewerId = getViewerId(); + const mine = (await tribesModel.listAll()).filter(t => t.author === viewerId); + for (const t of mine) { + await tribesModel.ensureTribeKeyDistribution(t.id).catch(() => {}); + await tribesModel.ensureFollowTribeMembers(t.id).catch(() => {}); + } + } catch (_) {} +}).catch(err => { + if (config.debug) console.error('tribe-keys scan error:', err.message); +}); +courtsModel.processIncomingCourtsKeys().catch(err => { + if (config.debug) console.error('courts-keys scan error:', err.message); +}); const getVoteComments = async (voteId) => { const raw = await post.topicComments(voteId); return (raw || []).filter(c => c?.value?.content?.type === 'post' && c.value.content.root === voteId) @@ -389,7 +509,6 @@ const enrichWithComments = async (items, idKey = 'id') => { return items; }; const withCount = (item, comments) => ({ ...item, commentCount: comments.length }); - const resolveMapUrl = async (mapUrl) => { if (!mapUrl) return null; try { @@ -397,6 +516,7 @@ const resolveMapUrl = async (mapUrl) => { return await mapsModel.getMapById(mapKey, null); } catch (_) { return null; } }; + const mediaResolvers = { images: id => imagesModel.resolveRootId(id), audios: id => audiosModel.resolveRootId(id), @@ -469,6 +589,12 @@ const qp = (ctx, def = 1) => Math.max(1, parseInt(ctx.query.page) || def); about._startNameWarmup(); async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { if (!text) return ''; + const escHtml = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); const mentionByFeed = {}; Object.values(mentions).forEach(arr => { arr.forEach(m => { @@ -476,7 +602,7 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { }); }); text = text.replace(/\[@([^\]]+)\]\(([^)]+)\)/g, (_, name, id) => { - return `@${name}`; + return `@${escHtml(name)}`; }); const words = text.split(' '); text = (await Promise.all( @@ -491,7 +617,7 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { } else { try { resolvedName = await about.name(feedWithAt); } catch { resolvedName = feedId.slice(0, 8); } } - return word.replace(match[0], `@${resolvedName}`); + return word.replace(match[0], `@${escHtml(resolvedName)}`); } return word; }) @@ -505,7 +631,8 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) { ``) .replace(/\[pdf:([^\]]*)\]\(([^)]+)\)/g, (_, name, id) => { const { i18n } = require("../views/main_views"); - return `${name || (i18n && i18n.pdfFallbackLabel) || 'PDF'}`; + const label = name || (i18n && i18n.pdfFallbackLabel) || 'PDF'; + return `${escHtml(label)}`; }); return text; } @@ -533,6 +660,7 @@ async function resolveMentionText(text) { const preparePreview = async function (ctx) { let text = String(ctx.request.body.text || "") + if (text.length > 8000) text = text.slice(0, 8000) const contentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || "")) const ensureAt = (id) => { const s = String(id || "") @@ -683,8 +811,6 @@ const megabyte = Math.pow(2, 20); const maxSize = 50 * megabyte; const homeDir = os.homedir(); const blobsPath = path.join(homeDir, '.ssb', 'blobs', 'tmp'); -fs.mkdirSync(blobsPath, { recursive: true }); -const multipartOpts = (extra = {}) => ({ multipart: true, formidable: { uploadDir: blobsPath, maxFileSize: maxSize, ...extra } }); const gossipPath = path.join(homeDir, '.ssb', 'gossip.json'); const unfollowedPath = path.join(homeDir, '.ssb', 'gossip_unfollowed.json'); const ensureJSONFile = (p, init = []) => { fs.mkdirSync(path.dirname(p), { recursive: true }); if (!fs.existsSync(p)) fs.writeFileSync(p, JSON.stringify(init, null, 2), 'utf8'); }; @@ -744,7 +870,7 @@ const { indexingView } = require("../views/indexing_view"); const { pixeliaView } = require("../views/pixelia_view"); const { gamesView } = require("../views/games_view"); const { statsView } = require("../views/stats_view"); -const { tribesView, tribeView, renderInvitePage, renderGovernance: renderTribeGovernance } = require("../views/tribes_view"); +const { tribesView, tribeView, renderInvitePage } = require("../views/tribes_view"); const { agendaView } = require("../views/agenda_view"); const { documentView, singleDocumentView } = require("../views/document_view"); const { inhabitantsView, inhabitantsProfileView } = require("../views/inhabitants_view"); @@ -765,6 +891,7 @@ const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view const { legacyView } = require("../views/legacy_view"); const { opinionsView } = require("../views/opinions_view"); const { peersView } = require("../views/peers_view"); +const { graphosView } = require("../views/graphos_view"); const { searchView } = require("../views/search_view"); const { transferView, singleTransferView } = require("../views/transfer_view"); const { cipherView } = require("../views/cipher_view"); @@ -780,7 +907,7 @@ const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view" const { shopsView, singleShopView, singleProductView, editProductView } = require("../views/shops_view"); const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); -const { calendarsView, singleCalendarView } = require("../views/calendars_view"); +const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view"); const { projectsView, singleProjectView } = require("../views/projects_view") const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views") const { favoritesView } = require("../views/favorites_view"); @@ -810,6 +937,38 @@ const tooLong = (ctx, value, max, label) => { } return false; }; + +const buildEffectivePrivateChainIds = async () => { + const ids = new Set(); + const all = await tribesModel.listAll().catch(() => []); + for (const tr of all) { + try { + const eff = await tribesModel.getEffectiveStatus(tr.id); + if (!eff || !eff.isPrivate) continue; + const chain = await tribesModel.getChainIds(tr.id).catch(() => [tr.id]); + for (const cid of chain) ids.add(cid); + } catch (_) {} + } + return ids; +}; + +const isBlockRestricted = (block, effPrivateChainIds) => { + if (!block) return false; + const c = block.content || {}; + const t = c.type || block.type || ''; + const isPrivate = String(c.isPublic || '').toLowerCase() === 'private'; + const tribeMsgInPrivate = t === 'tribe' && (effPrivateChainIds.has(block.id) || (c.replaces && effPrivateChainIds.has(c.replaces))); + const tribeKeysInPrivate = t === 'tribe-keys' && c.tribeId && effPrivateChainIds.has(c.tribeId); + const tribeContentInPrivate = !!c.tribeId && effPrivateChainIds.has(c.tribeId); + return tribeMsgInPrivate || + tribeKeysInPrivate || + tribeContentInPrivate || + t.startsWith('courts') || + t === 'job' || t === 'job_sub' || + c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' || + isPrivate; +}; + router .param("imageSize", (imageSize, ctx, next) => { const size = Number(imageSize); @@ -855,6 +1014,7 @@ router myAddress: myAddress || null, totalAddresses: Array.isArray(addrRows) ? addrRows.length : 0 }; + try { stats.logsCount = await logsModel.countLogs(); } catch { stats.logsCount = 0; } const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize); const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2)); const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1; @@ -870,7 +1030,7 @@ router ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))) }); }) .get("/modules", async (ctx) => { - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament', 'courts']; + const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts']; const cfg = getConfig().modules; ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); }) @@ -882,11 +1042,6 @@ router let chatHistory = []; try { chatHistory = JSON.parse(fs.readFileSync(historyPath, 'utf-8')); } catch {} ctx.body = aiView(chatHistory, getConfig().ai?.prompt?.trim() || ''); }) - .get('/pixelia', async (ctx) => { - if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; } - const pixelArt = await pixeliaModel.listPixels(); - ctx.body = pixeliaView(pixelArt); - }) .get('/games', async (ctx) => { if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; } const filter = qf(ctx, 'all'); @@ -898,6 +1053,17 @@ router const { gameShellView } = require('../views/games_view'); ctx.body = gameShellView(ctx.params.name); }) + .post('/games/submit-score', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; } + const { game, score } = ctx.request.body; + try { await gamesModel.submitScore(game, score); } catch (_) {} + ctx.redirect('/games?filter=scoring'); + }) + .get('/pixelia', async (ctx) => { + if (!checkMod(ctx, 'pixeliaMod')) { ctx.redirect('/modules'); return; } + const pixelArt = await pixeliaModel.listPixels(); + ctx.body = pixeliaView(pixelArt); + }) .get('/blockexplorer', async (ctx) => { const userId = getViewerId(); const query = ctx.query || {}; @@ -911,6 +1077,10 @@ router let filter = query.filter || 'recent'; if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all'; const blockchainData = await blockchainModel.listBlockchain(filter, userId, search); + const effPrivateChainIds = await buildEffectivePrivateChainIds(); + for (const block of blockchainData) { + block.restricted = isBlockRestricted(block, effPrivateChainIds); + } ctx.body = renderBlockchainView(blockchainData, filter, userId, search); }) .get('/blockexplorer/block/:id', async (ctx) => { @@ -926,9 +1096,23 @@ router let filter = query.filter || 'recent'; if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all'; const blockId = ctx.params.id; - const block = await blockchainModel.getBlockById(blockId); + let block = await blockchainModel.getBlockById(blockId, userId); const viewMode = query.view || 'block'; - ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode); + let restricted = false; + if (block) { + const effPrivateChainIds = await buildEffectivePrivateChainIds(); + restricted = isBlockRestricted(block, effPrivateChainIds); + const c = block.content || {}; + if (!restricted && c.encryptedPayload && tribeCrypto) { + try { + const decrypted = await tribeCrypto.decryptFromTribe(c, tribesModel); + if (decrypted && !decrypted._undecryptable) { + block = { ...block, content: decrypted }; + } + } catch (_) {} + } + } + ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted); }) .get("/public/latest", async (ctx) => { if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; } @@ -972,16 +1156,50 @@ router const pull = require('../server/node_modules/pull-stream'), ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open(); const latestFromStream = await new Promise(res => pull(ssb.createUserStream({ id: feedId, reverse: true }), pull.filter(m => m?.value?.content?.type !== 'tombstone'), pull.take(1), pull.collect((err, arr) => res(!err && arr?.[0] ? normTs(arr[0].value?.timestamp || arr[0].timestamp) : 0)))); const days = latestFromStream ? (Date.now() - latestFromStream) / 86400000 : Infinity; - ctx.body = await authorView({ feedId, messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, lastActivityBucket: days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red' }); + ctx.body = await authorView({ feedId, messages: sanitizedMsgs, firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, lastActivityBucket: days < 14 ? 'green' : days < 182.5 ? 'orange' : 'red' }); }) .get("/search", async (ctx) => { const query = ctx.query.query || ''; if (!query) return ctx.body = await searchView({ messages: [], query, types: [] }); + const userId = getViewerId(); + const allTribes = await tribesModel.listAll(); + const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id)); + const applySearchPrivacy = (msgs) => msgs.filter(msg => { + const c = msg.value?.content; + if (!c) return true; + if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; + if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false; + if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false; + if (c.status === 'PRIVATE') return false; + if (c.type === 'shop' && c.visibility === 'CLOSED' && c.author !== userId) return false; + return true; + }); const results = await searchModel.search({ query, types: [] }); - ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => { - acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); - return acc; - }, {}), query, types: [] }); + const cfgNow = getConfig(); + const wishMutuals = cfgNow.wish === 'mutuals'; + const mutualCache = wishMutuals ? makeCtxMutualCache() : null; + const accessSets = await getViewerTribeAccessSets(userId); + const finalResults = {}; + for (const [type, msgs] of Object.entries(results)) { + const privacyFiltered = applySearchPrivacy(msgs).filter(msg => { + const c = msg.value?.content; + if (c && c.tribeId && accessSets.privateNotAccessible.has(c.tribeId)) return false; + return true; + }); + let after = privacyFiltered; + if (wishMutuals) { + const out = []; + for (const m of privacyFiltered) { + const a = m.value?.author || m.value?.content?.author; + if (!a || a === userId) { out.push(m); continue; } + if (await mutualCache(a)) out.push(m); + } + after = out; + } + const mapped = after.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); + if (mapped.length > 0) finalResults[type] = mapped; + } + ctx.body = await searchView({ results: finalResults, query, types: [] }); }) .get("/images", async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } @@ -990,6 +1208,7 @@ router const fav = await mediaFavorites.getFavoriteSet('images'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); ctx.body = await imageView(enriched, filter, null, { q, sort }); }) @@ -1016,7 +1235,7 @@ router let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); const myTribeIds = await getUserTribeIds(uid); - enriched = enriched.filter(x => !x.tribeId || myTribeIds.has(x.tribeId)); + enriched = enriched.filter(x => !x.tribeId); enriched = await applyListFilters(enriched, ctx); try { ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) }); @@ -1027,7 +1246,10 @@ router }) .get("/maps/edit/:id", async (ctx) => { if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } - const mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); + let mapItem; + try { mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/maps?filter=all'); return; } + if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } + if (mapItem.author !== getViewerId()) { ctx.redirect(`/maps/${encodeURIComponent(mapItem.key)}`); return; } const fav = await mediaFavorites.getFavoriteSet('maps'); ctx.body = await mapsView([{ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }], 'edit', mapItem.key, { returnTo: ctx.query.returnTo || '' }); }) @@ -1035,7 +1257,14 @@ router if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query; const uid = getViewerId(); - const mapItem = await mapsModel.getMapById(mapId, uid); + let mapItem; + try { + mapItem = await mapsModel.getMapById(mapId, uid); + } catch (e) { + ctx.redirect('/maps?filter=all'); + return; + } + if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } const fav = await mediaFavorites.getFavoriteSet('maps'); let tribeMembers = []; let parentTribe = null; @@ -1045,8 +1274,13 @@ router if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; } tribeMembers = parentTribe.members; } catch { ctx.redirect('/tribes'); return; } + } else { + const members = Array.isArray(mapItem.members) ? mapItem.members : []; + const mt = String(mapItem.mapType || '').toUpperCase(); + const isOpenAccess = mt === 'OPEN' || mt === 'SINGLE'; + if (!isOpenAccess && mapItem.author !== uid && !members.includes(uid)) { ctx.redirect('/maps?filter=all'); return; } } - if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid && mapItem.tribeId) { + if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid) { ctx.body = tribeAccessDeniedView(parentTribe); return; } ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) }); @@ -1058,6 +1292,7 @@ router const fav = await mediaFavorites.getFavoriteSet('audios'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); ctx.body = await audioView(enriched, filter, null, { q, sort }); }) @@ -1077,13 +1312,13 @@ router }) .get("/torrents", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } - const { filter = 'all', q = '', sort = 'recent' } = ctx.query; + const { filter = 'all', q = '', sort = 'recent', tribeId = '' } = ctx.query; const items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); const fav = await mediaFavorites.getFavoriteSet('torrents'); - let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + let enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); - ctx.body = await torrentsView(enriched, filter, null, { q, sort }); + ctx.body = await torrentsView(enriched, filter, null, { q, sort, ...(tribeId ? { tribeId } : {}) }); }) .get("/torrents/edit/:id", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } @@ -1106,6 +1341,7 @@ router const fav = await mediaFavorites.getFavoriteSet('videos'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.key)).length; })); ctx.body = await videoView(enriched, filter, null, { q, sort }); }) @@ -1125,17 +1361,24 @@ router }) .get("/documents", async (ctx) => { const { filter = 'all', q = '', sort = 'recent' } = ctx.query; - const items = await documentsModel.listAll({ filter, q, sort }); - await Promise.all(items.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; })); - ctx.body = await documentView(items, filter, null, { q, sort }); + const items = await documentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort }); + const fav = await mediaFavorites.getFavoriteSet('documents'); + let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); + enriched = await applyListFilters(enriched, ctx); + await Promise.all(enriched.map(async x => { x.commentCount = (await getVoteComments(x.rootId || x.key)).length; })); + ctx.body = await documentView(enriched, filter, null, { q, sort }); }) .get("/documents/edit/:id", async (ctx) => { const doc = await documentsModel.getDocumentById(ctx.params.id); - ctx.body = await documentView([doc], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' }); + const fav = await mediaFavorites.getFavoriteSet('documents'); + ctx.body = await documentView([{ ...doc, isFavorite: fav.has(String(doc.rootId || doc.key)) }], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/documents/:documentId", async (ctx) => { const { filter = "all", q = "", sort = "recent" } = ctx.query; const document = await documentsModel.getDocumentById(ctx.params.documentId); + const fav = await mediaFavorites.getFavoriteSet('documents'); + Object.assign(document, { isFavorite: fav.has(String(document.rootId || document.key)) }); const comments = await getVoteComments(document.rootId || document.key); ctx.body = await singleDocumentView(withCount(document, comments), filter, comments, { q, sort, @@ -1161,7 +1404,28 @@ router }) .get('/inbox', async ctx => { if (!checkMod(ctx, 'inboxMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await pmModel.listAllPrivate()); + let messages = sanitizeMessages(await pmModel.listAllPrivate()); + const cfgNow = getConfig(); + if (cfgNow.pmVisibility === 'mutuals') { + const viewer = getViewerId(); + const mutualCache = new Map(); + const isMutual = async (id) => { + if (id === viewer) return true; + if (mutualCache.has(id)) return mutualCache.get(id); + let rel; + try { rel = await friend.getRelationship(id); } catch (e) { rel = null; } + const m = !!(rel && rel.following && rel.followsMe); + mutualCache.set(id, m); + return m; + }; + const filtered = []; + for (const msg of messages) { + const author = msg?.value?.author || msg?.author; + if (author === viewer) { filtered.push(msg); continue; } + if (await isMutual(author)) filtered.push(msg); + } + messages = filtered; + } await refreshInboxCount(messages); ctx.body = await privateView({ messages }, ctx.query.filter || undefined); }) @@ -1170,7 +1434,9 @@ router ctx.body = await tagsView(tags, filter); }) .get('/reports', async ctx => { - const filter = qf(ctx), reports = await enrichWithComments(await reportsModel.listAll()); + const filter = qf(ctx); + let reports = await enrichWithComments(await reportsModel.listAll()); + reports = await applyListFilters(reports, ctx); ctx.body = await reportView(reports, filter, null, ctx.query.category || ''); }) .get('/reports/edit/:id', async ctx => { @@ -1183,11 +1449,15 @@ router ctx.body = await singleReportView(withCount(report, comments), filter, comments); }) .get('/trending', async (ctx) => { - const filter = qf(ctx, 'RECENT'), { filtered = [] } = await trendingModel.listTrending(filter); + const filter = qf(ctx, 'RECENT'); + let { filtered = [] } = await trendingModel.listTrending(filter); + filtered = await applyListFilters(filtered, ctx); ctx.body = await trendingView(filtered, filter, trendingModel.categories); }) .get('/agenda', async (ctx) => { - const filter = qf(ctx), data = await agendaModel.listAgenda(filter); + const filter = qf(ctx); + let data = await agendaModel.listAgenda(filter); + if (Array.isArray(data)) data = await applyListFilters(data, ctx); ctx.body = await agendaView(data, filter); }) .get("/hashtag/:hashtag", async (ctx) => { @@ -1199,6 +1469,17 @@ router const filter = qf(ctx); const query = { search: ctx.query.search || '' }; const userId = getViewerId(); + if (filter === 'pending') { + try { await scanPendingFollows(userId); } catch (e) {} + const pending = viewerFilters.listPending(); + const enriched = await Promise.all(pending.map(async (p) => { + let name = p.followerId; + try { name = await about.name(p.followerId); } catch (_) {} + return { ...p, name }; + })); + ctx.body = renderPendingFollows(enriched); + return; + } if (['CVs', 'MATCHSKILLS'].includes(filter)) { Object.assign(query, { location: ctx.query.location || '', @@ -1213,9 +1494,9 @@ router inhabitants.map(async (u) => { try { const bank = await bankingModel.getBankingData(u.id); - return { id: u.id, karmaScore: bank?.karmaScore || 0 }; + return { id: u.id, karmaScore: bank?.karmaScore || 0, estimatedUBI: bank?.estimatedUBI || 0, lastClaimedDate: bank?.lastClaimedDate || null, totalClaimed: bank?.totalClaimed || 0 }; } catch { - return { id: u.id, karmaScore: 0 }; + return { id: u.id, karmaScore: 0, estimatedUBI: 0, lastClaimedDate: null, totalClaimed: 0 }; } }) ) @@ -1232,16 +1513,20 @@ router }) ); const addrMap = new Map(addresses.map(x => [x.id, x.address])); - const karmaMap = new Map(karmaList.map(x => [x.id, x.karmaScore])); + const karmaMap = new Map(karmaList.map(x => [x.id, { karmaScore: x.karmaScore, estimatedUBI: x.estimatedUBI, lastClaimedDate: x.lastClaimedDate, totalClaimed: x.totalClaimed }])); const activityMap = new Map(activityList.map(x => [x.id, x.lastActivityBucket])); - let enriched = inhabitants.map(u => ({ - ...u, - ecoAddress: addrMap.get(u.id) || null, - karmaScore: - karmaMap.get(u.id) ?? - (typeof u.karmaScore === 'number' ? u.karmaScore : 0), - lastActivityBucket: activityMap.get(u.id) - })); + let enriched = inhabitants.map(u => { + const kd = karmaMap.get(u.id) || {}; + return { + ...u, + ecoAddress: addrMap.get(u.id) || null, + karmaScore: kd.karmaScore ?? (typeof u.karmaScore === 'number' ? u.karmaScore : 0), + estimatedUBI: kd.estimatedUBI || 0, + lastClaimedDate: kd.lastClaimedDate || null, + totalClaimed: kd.totalClaimed || 0, + lastActivityBucket: activityMap.get(u.id) + }; + }); if (filter === 'TOP KARMA') { enriched = enriched.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0)); } @@ -1266,7 +1551,10 @@ router const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null); const currentUserId = getViewerId(); const karmaScore = bank && typeof bank.karmaScore === 'number' ? bank.karmaScore : 0; - ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId); + const estimatedUBI = bank?.estimatedUBI || 0; + const lastClaimedDate = bank?.lastClaimedDate || null; + const totalClaimed = bank?.totalClaimed || 0; + ctx.body = await inhabitantsProfileView({ about, cv, feed, photo, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, lastActivityBucket: bucketInfo.bucket, viewedId: id }, currentUserId); }) .get('/parliament', async (ctx) => { if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules'); @@ -1357,7 +1645,8 @@ router }) .get('/tribes', async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } - const filter = qf(ctx), search = ctx.query.search || '', tribes = await tribesModel.listAll(); + const uid = getViewerId(); + const filter = qf(ctx), search = ctx.query.search || '', tribes = await tribesModel.listTribesForViewer(uid); const filteredTribes = search ? tribes.filter(t => t.title.toLowerCase().includes(search.toLowerCase())) : tribes; ctx.body = await tribesView(filteredTribes, filter, null, ctx.query, tribes); }) @@ -1372,14 +1661,17 @@ router }) .get('/tribe/:tribeId', async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } + await tribesModel.processIncomingKeys().catch(() => {}); + await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {}); + await tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {}); const listByTribeAllChain = async (tribeId, contentType) => { const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]); const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => []))); const seen = new Set(); return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; }); }; - const tribe = await tribesModel.getTribeById(ctx.params.tribeId); - tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {}); + const tribe = await tribesModel.getTribeById(ctx.params.tribeId).catch(() => null); + if (!tribe) { ctx.redirect('/tribes'); return; } const uid = getViewerId(); const query = { feedFilter: 'TOP', ...ctx.query }; if (!tribe.members.includes(uid)) { @@ -1400,20 +1692,37 @@ router const replies = await listByTribeAllChain(tribe.id, 'forum-reply'); sectionData = [...forums, ...replies]; } else if (section === 'subtribes') { - sectionData = await tribesModel.listSubTribes(tribe.id); + sectionData = await tribesModel.listSubTribes(tribe.id, uid); } else if (mediaSections[section]) { sectionData = await listByTribeAllChain(tribe.id, 'media'); } else if (contentTypeMap[section]) { sectionData = await listByTribeAllChain(tribe.id, contentTypeMap[section]); } else if (section === 'activity') { const allContent = await listByTribeAllChain(tribe.id, null); - const subTribes = await tribesModel.listSubTribes(tribe.id); + const subTribes = await tribesModel.listSubTribes(tribe.id, uid); const subContent = []; for (const st of subTribes) { const stItems = await listByTribeAllChain(st.id, null).catch(() => []); subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title }))); } - const combined = [...allContent, ...subContent]; + const [allPadsRaw, allChatsRaw, allCalsRaw, allMapsRaw, allTorrentsRaw, tribeChain] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) }); + const standaloneItems = [ + ...allPadsRaw.filter(p => tribeChainSet.has(p.tribeId)).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)), + ...allChatsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)), + ...allCalsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)), + ...allMapsRaw.filter(m => tribeChainSet.has(m.tribeId)).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`)), + ...allTorrentsRaw.filter(t => tribeChainSet.has(t.tribeId)).map(toStandalone('torrent', t => `/torrents/${encodeURIComponent(t.rootId || t.key)}`)) + ]; + const combined = [...allContent, ...subContent, ...standaloneItems]; const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true }); const allMembers = [...new Set([...tribe.members, ...subTribes.flatMap(st => st.members || [])])]; const memberMap = new Map(allInhabitants.filter(u => allMembers.includes(u.id)).map(u => [u.id, u])); @@ -1432,10 +1741,29 @@ router sectionData = { items, period }; } else if (section === 'tags') { const allContent = await listByTribeAllChain(tribe.id, null); + const [allPadsT, allChatsT, allCalsT, allMapsT, allTorrentsT, subTribesT, tribeChainT] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.listSubTribes(tribe.id, uid).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSetT = new Set(tribeChainT); + const standaloneTagged = [ + ...allPadsT.filter(p => tribeChainSetT.has(p.tribeId)).map(p => ({ ...p, contentType: 'pad', id: p.rootId || p.key })), + ...allChatsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'chat', id: c.rootId || c.key })), + ...allCalsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'calendar', id: c.rootId || c.key })), + ...allMapsT.filter(m => tribeChainSetT.has(m.tribeId)).map(m => ({ ...m, contentType: 'map', id: m.rootId || m.key })), + ...allTorrentsT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'torrent', id: t.rootId || t.key })), + ...subTribesT.map(st => ({ ...st, contentType: 'tribe', tags: Array.isArray(st.tags) ? st.tags : [], title: st.title, description: st.description, author: st.author, createdAt: st.createdAt })) + ]; + const allTaggable = [...allContent, ...standaloneTagged]; const tagMap = new Map(); - for (const item of allContent) { + for (const item of allTaggable) { for (const tag of (item.tags || []).filter(Boolean)) { - const lower = tag.toLowerCase().trim(); + const lower = String(tag).toLowerCase().trim(); if (!lower) continue; if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] }); const entry = tagMap.get(lower); @@ -1445,6 +1773,57 @@ router } const selectedTag = (ctx.query.tag || '').toLowerCase().trim(); sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count), selectedTag, filteredItems: selectedTag && tagMap.has(selectedTag) ? tagMap.get(selectedTag).items : [] }; + } else if (section === 'maps') { + const [allMaps, tribeChain] = await Promise.all([ + mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allMaps.filter(m => tribeChainSet.has(m.tribeId)); + } else if (section === 'pads') { + const [allPads, tribeChain] = await Promise.all([ + padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allPads.filter(p => tribeChainSet.has(p.tribeId)); + } else if (section === 'chats') { + const [allChats, tribeChain] = await Promise.all([ + chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allChats.filter(c => tribeChainSet.has(c.tribeId)); + } else if (section === 'calendars') { + const [allCals, tribeChain] = await Promise.all([ + calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + sectionData = allCals.filter(c => tribeChainSet.has(c.tribeId)); + } else if (section === 'torrents') { + const [allTorrents, tribeChain] = await Promise.all([ + torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []), + tribesModel.getChainIds(tribe.id).catch(() => [tribe.id]) + ]); + const tribeChainSet = new Set(tribeChain); + const standaloneTorrents = allTorrents.filter(t => tribeChainSet.has(t.tribeId)); + const mediaTorrents = (await listByTribeAllChain(tribe.id, 'media').catch(() => [])) + .filter(m => m.mediaType === 'torrent') + .map(m => ({ + key: m.id, + rootId: m.id, + title: m.title || '', + description: m.description || '', + url: m.image || '', + tags: Array.isArray(m.tags) ? m.tags : [], + author: m.author, + createdAt: m.createdAt, + updatedAt: m.updatedAt, + tribeId: m.tribeId, + _isMedia: true + })); + sectionData = [...standaloneTorrents, ...mediaTorrents].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } else if (section === 'search') { const sq = (ctx.query.q || '').trim().toLowerCase(); let results = []; @@ -1470,8 +1849,25 @@ router const tasks = await listByTribeAllChain(tribe.id, 'task').catch(() => []); const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []); sectionData = { events, tasks, feed }; + } else if (section === 'governance') { + if (tribe.parentTribeId) { ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}?section=activity`); return; } + const gf = String(ctx.query.filter || 'government'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (isCreator) { try { await parliamentModel.tribe.ensureTerm(tribe.id); } catch (_) {} } + const [term, candidatures, rules, globalTermBase] = await Promise.all([ + parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null), + parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []), + parliamentModel.tribe.listRules(tribe.id).catch(() => []), + parliamentModel.getCurrentTerm().catch(() => null) + ]); + const globalStart = globalTermBase?.startAt || null; + const alreadyPublishedThisGlobalCycle = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribe.id, globalStart).catch(() => false); + const leaders = Array.isArray(term?.leaders) ? term.leaders : []; + const hasElectedCandidate = Array.isArray(candidatures) && candidatures.some(c => (c.status || 'OPEN') === 'OPEN' && Number(c.votes || 0) > 0); + sectionData = { filter: gf, term, candidatures, rules, leaders, isCreator, isMember, canPublishToGlobal: isMember || isCreator, alreadyPublishedThisGlobalCycle, hasElectedCandidate }; } - const subTribes = await tribesModel.listSubTribes(tribe.id); + const subTribes = await tribesModel.listSubTribes(tribe.id, uid); tribe.subTribes = subTribes; if (tribe.parentTribeId) { try { tribe.parentTribe = await tribesModel.getTribeById(tribe.parentTribeId); } catch (_) {} @@ -1500,7 +1896,19 @@ router const q = String((ctx.query && ctx.query.q) || ''); try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {} try { await bankingModel.getUserEngagementScore(userId); } catch (_) {} - const allActions = await activityModel.listFeed('all'); + let allActions = await activityModel.listFeed('all'); + for (const action of allActions) { + if (action.type === 'pad') { + const c = action.value?.content || action.content || {}; + const rootId = action.id || action.key || ''; + const decrypted = await padsModel.decryptContent(c, rootId); + if (decrypted.title) { + if (action.value?.content) { action.value.content.title = decrypted.title; action.value.content.deadline = decrypted.deadline; } + else if (action.content) { action.content.title = decrypted.title; action.content.deadline = decrypted.deadline; } + } + } + } + allActions = await applyListFilters(allActions, ctx); ctx.body = activityView(allActions, filter, userId, q); }) .get("/profile", async (ctx) => { @@ -1518,13 +1926,13 @@ router lastActivityTs = await new Promise(res => pull(ssb.createUserStream({ id: myFeedId, reverse: true }), pull.filter(m => m?.value?.content?.type !== "tombstone"), pull.take(1), pull.collect((err, arr) => res(!err && arr?.[0] ? normTs(arr[0].value?.timestamp || arr[0].timestamp) : 0)))); } const days = lastActivityTs ? (Date.now() - lastActivityTs) / 86400000 : Infinity; - ctx.body = await authorView({ feedId: myFeedId, messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, lastActivityBucket: days < 14 ? "green" : days < 182.5 ? "orange" : "red" }); + ctx.body = await authorView({ feedId: myFeedId, messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, lastActivityBucket: days < 14 ? "green" : days < 182.5 ? "orange" : "red" }); }) .get("/profile/edit", async (ctx) => { const myFeedId = await meta.myFeedId(); ctx.body = await editProfileView({ name: await about.name(myFeedId), description: await about.description(myFeedId) }); }) - .post("/profile/edit", koaBody(multipartOpts()), async (ctx) => { + .post("/profile/edit", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const imageFile = ctx.request.files?.image; const mime = imageFile?.mimetype || imageFile?.type || ''; const isImage = mime.startsWith('image/'); @@ -1599,12 +2007,62 @@ router }) .get("/settings", async (ctx) => { const cfg = getConfig(), theme = ctx.cookies.get("theme") || "Dark-SNH"; - ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "", pubWalletUrl: cfg.walletPub?.url || '', pubWalletUser: cfg.walletPub?.user || '', pubWalletPass: cfg.walletPub?.pass || '' }); + ctx.body = await settingsView({ theme, version: version.toString(), aiPrompt: cfg.ai?.prompt || "" }); }) .get("/peers", async (ctx) => { const { discoveredPeers, unknownPeers } = await meta.discovered(); ctx.body = await peersView({ onlinePeers: await meta.onlinePeers(), discoveredPeers, unknownPeers }); }) + .get("/graphos", async (ctx) => { + if (!checkMod(ctx, 'graphosMod')) return ctx.redirect('/modules'); + const filter = String(ctx.query?.filter || 'ALL').toUpperCase() === 'MINE' ? 'MINE' : 'ALL'; + const onlinePeers = await meta.onlinePeers(); + const { discoveredPeers, unknownPeers } = filter === 'MINE' + ? { discoveredPeers: [], unknownPeers: [] } + : await meta.discovered(); + const ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open(); + const myId = ssb.id; + const shortId = (key) => { + const core = String(key).replace(/^@/, '').replace(/\.ed25519$/, ''); + return '@' + core.slice(0, 8) + '…'; + }; + const resolveName = async (key) => { + try { + const n = await about.name(key); + if (!n) return shortId(key); + if (n === 'Redacted') return shortId(key); + if (n === String(key).replace(/^@/, '').slice(0, 8)) return shortId(key); + return n; + } catch { + return shortId(key); + } + }; + const seen = new Set([myId]); + const collected = []; + const collect = (entries, kind) => { + for (const [, data] of entries) { + if (!data || !data.key || seen.has(data.key)) continue; + seen.add(data.key); + collected.push({ key: data.key, kind }); + } + }; + collect(onlinePeers, 'online'); + collect(discoveredPeers, 'discovered'); + collect(unknownPeers, 'unknown'); + const peers = await Promise.all(collected.map(async (p) => ({ + key: p.key, + name: await resolveName(p.key), + kind: p.kind + }))); + const me = { key: myId, name: await resolveName(myId) }; + const kpis = { + total: peers.length + 1, + online: onlinePeers.length, + discovered: discoveredPeers.length, + unknown: unknownPeers.length + }; + ctx.body = await graphosView({ filter, me, peers, kpis }); + }) .get("/invites", async (ctx) => { if (!checkMod(ctx, 'invitesMod')) return ctx.redirect('/modules'); ctx.body = await invitesView({}); @@ -1658,7 +2116,9 @@ router ctx.body = await mentionsView({ messages: combined, myFeedId }); }) .get('/opinions', async (ctx) => { - const filter = qf(ctx, 'RECENT'), opinions = await opinionsModel.listOpinions(filter); + const filter = qf(ctx, 'RECENT'); + let opinions = await opinionsModel.listOpinions(filter); + if (Array.isArray(opinions)) opinions = await applyListFilters(opinions, ctx); ctx.body = await opinionsView(opinions, filter); }) .get("/feed", async (ctx) => { @@ -1666,7 +2126,8 @@ router const q = typeof ctx.query.q === "string" ? ctx.query.q : ""; const tag = typeof ctx.query.tag === "string" ? ctx.query.tag : ""; const msg = typeof ctx.query.msg === "string" ? ctx.query.msg : ""; - const feeds = await feedModel.listFeeds({ filter, q, tag }); + let feeds = await feedModel.listFeeds({ filter, q, tag }); + feeds = await applyListFilters(feeds, ctx); ctx.body = feedView(feeds, { filter, q, tag, msg }); }) .get("/feed/create", async (ctx) => { @@ -1682,7 +2143,9 @@ router }) .get('/forum', async ctx => { if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } - const filter = qf(ctx, 'recent'), forums = await forumModel.listAll(filter); + const filter = qf(ctx, 'hot'); + let forums = await forumModel.listAll(filter); + forums = await applyListFilters(forums, ctx); ctx.body = await forumView(forums, filter); }) .get('/forum/:forumId', async ctx => { @@ -1691,7 +2154,7 @@ router }) .get('/legacy', async (ctx) => { if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules'); - try { ctx.body = await legacyView(); } catch (error) { ctx.body = { error: error.message }; } + try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); } }) .get('/bookmarks', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); @@ -1699,13 +2162,14 @@ router const favs = await mediaFavorites.getFavoriteSet("bookmarks"); let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) })); if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite); + bookmarks = await applyListFilters(bookmarks, ctx); await enrichWithComments(bookmarks, 'rootId'); ctx.body = await bookmarkView(bookmarks, filter, null, { q, sort }); }) .get("/bookmarks/edit/:id", async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); - const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoritesSet("bookmarks"); - ctx.body = await bookmarkView([{ ...bookmark, isFav: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" }); + const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoriteSet("bookmarks"); + ctx.body = await bookmarkView([{ ...bookmark, isFavorite: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" }); }) .get('/bookmarks/:bookmarkId', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); @@ -1714,7 +2178,9 @@ router ctx.body = await singleBookmarkView({ ...bookmark, commentCount: comments.length, isFavorite: favs.has(String(root)) }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/bookmarks?filter=${encodeURIComponent(filter)}`, ['/bookmarks']) }); }) .get('/tasks', async ctx => { - const filter = qf(ctx), tasks = await enrichWithComments(await tasksModel.listAll()); + const filter = qf(ctx); + let tasks = await enrichWithComments(await tasksModel.listAll()); + tasks = await applyListFilters(tasks, ctx); ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo); }) .get('/tasks/edit/:id', async ctx => { @@ -1729,7 +2195,9 @@ router }) .get('/events', async (ctx) => { if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } - const filter = qf(ctx), events = await enrichWithComments(await eventsModel.listAll(null, filter)); + const filter = qf(ctx); + let events = await enrichWithComments(await eventsModel.listAll(null, filter)); + events = await applyListFilters(events, ctx); ctx.body = await eventView(events, filter, null, ctx.query.returnTo); }) .get('/events/edit/:id', async (ctx) => { @@ -1740,11 +2208,13 @@ router }) .get('/events/:eventId', async ctx => { const { eventId } = ctx.params, filter = qf(ctx), event = await eventsModel.getEventById(eventId); - const comments = await getVoteComments(eventId); - ctx.body = await singleEventView(withCount(event, comments), filter, comments); + const [comments, mapData] = await Promise.all([getVoteComments(eventId), resolveMapUrl(event.mapUrl)]); + ctx.body = await singleEventView(withCount(event, comments), filter, comments, { mapData }); }) .get('/votes', async ctx => { - const filter = qf(ctx), voteList = await enrichWithComments(await votesModel.listAll(filter)); + const filter = qf(ctx); + let voteList = await enrichWithComments(await votesModel.listAll(filter)); + voteList = await applyListFilters(voteList, ctx); ctx.body = await voteView(voteList, filter, null, [], filter); }) .get('/votes/edit/:id', async ctx => { @@ -1765,6 +2235,7 @@ router await marketModel.checkAuctionItemsStatus(marketItems); marketItems = await marketModel.listAllItems("all"); await enrichWithComments(marketItems); + marketItems = await applyListFilters(marketItems, ctx); ctx.body = await marketView(marketItems, filter, null, { q, minPrice, maxPrice, sort }); }) .get("/market/edit/:id", async (ctx) => { @@ -1785,7 +2256,8 @@ router await marketModel.checkAuctionItemsStatus([item]) item = await marketModel.getItemById(itemId) if (!item) ctx.throw(404, "Item not found") - const comments = await getVoteComments(itemId) + const zoom = parseInt(ctx.query.zoom) || 2; + const [comments, mapData] = await Promise.all([getVoteComments(itemId), resolveMapUrl(item.mapUrl)]) const returnTo = (() => { const params = [] if (filter) params.push(`filter=${encodeURIComponent(filter)}`) @@ -1795,7 +2267,7 @@ router if (sort) params.push(`sort=${encodeURIComponent(sort)}`) return `/market${params.length ? `?${params.join("&")}` : ""}` })() - ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo }) + ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom }) }) .get('/jobs', async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } @@ -1823,8 +2295,9 @@ router return } const viewerId = getViewerId() - const jobs = await jobsModel.listJobs(filter, viewerId, query) + let jobs = await jobsModel.listJobs(filter, viewerId, query) await enrichWithComments(jobs) + jobs = await applyListFilters(jobs, ctx) ctx.body = await jobsView(jobs, filter, query) }) .get('/jobs/edit/:id', async (ctx) => { @@ -1848,8 +2321,8 @@ router returnTo: safeReturnTo(ctx, `/jobs?filter=${encodeURIComponent(filter)}`, ['/jobs']) } const job = await jobsModel.getJobById(jobId, viewerId) - const comments = await getVoteComments(jobId) - ctx.body = await singleJobsView(withCount(job, comments), filter, comments, params) + const [comments, mapData] = await Promise.all([getVoteComments(jobId), resolveMapUrl(job.mapUrl)]) + ctx.body = await singleJobsView(withCount(job, comments), filter, comments, { ...params, mapData }) }) .get("/shops", async (ctx) => { if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } @@ -1918,7 +2391,7 @@ router const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId }); const fav = await mediaFavorites.getFavoriteSet('chats'); const myTribeIds = await getUserTribeIds(viewerId); - const enriched = items.filter(x => !x.tribeId || myTribeIds.has(x.tribeId)).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); + const enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched; finalList = await applyListFilters(finalList, ctx); ctx.body = await chatsView(finalList, filter, null, { q }); @@ -1943,14 +2416,15 @@ router await tribesModel.processIncomingKeys().catch(() => {}); chat = await chatsModel.getChatById(ctx.params.chatId); } catch { ctx.redirect('/tribes'); return; } - } - if (String(chat.status || '').toUpperCase() === 'INVITE-ONLY' && chat.author !== uid) { - const invited = Array.isArray(chat.invites) && chat.invites.includes(uid); - if (!invited) { ctx.body = inviteRequiredView('chat', parentTribe); return; } + } else { + const members = Array.isArray(chat.members) ? chat.members : []; + const isOpen = String(chat.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; } } const fav = await mediaFavorites.getFavoriteSet('chats'); const messages = await chatsModel.listMessages(chat.rootId || chat.key); - ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) }); + const isTribeMember = !!parentTribe; + ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) }); }) .get("/pads", async (ctx) => { if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } @@ -1985,10 +2459,10 @@ router await tribesModel.processIncomingKeys().catch(() => {}); pad = await padsModel.getPadById(ctx.params.padId); } catch { ctx.redirect('/tribes'); return; } - } - if (String(pad.status || '').toUpperCase() === 'INVITE-ONLY' && pad.author !== uid) { - const invited = Array.isArray(pad.invites) && pad.invites.includes(uid); - if (!invited) { ctx.body = inviteRequiredView('pad', parentTribe); return; } + } else { + const members = Array.isArray(pad.members) ? pad.members : []; + const isOpen = String(pad.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && pad.author !== uid && !members.includes(uid)) { ctx.redirect('/pads?filter=all'); return; } } const fav = await mediaFavorites.getFavoriteSet('pads'); const entries = await padsModel.getEntries(pad.rootId); @@ -1997,7 +2471,8 @@ router ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null) : null; const baseUrl = `${ctx.protocol}://${ctx.host}`; - ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, selectedVersion }); + const isTribeMember = !!parentTribe; + ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)), isTribeMember }, entries, { baseUrl, selectedVersion }); }) .get("/calendars", async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } @@ -2017,7 +2492,7 @@ router const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid }); const fav = await mediaFavorites.getFavoriteSet('calendars'); const myTribeIds = await getUserTribeIds(uid); - const enriched = calendars.filter(c => !c.tribeId || myTribeIds.has(c.tribeId)).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) })); + const enriched = calendars.filter(c => !c.tribeId).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) })); let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched; finalList = await applyListFilters(finalList, ctx); ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) }); @@ -2033,6 +2508,10 @@ router parentTribe = await tribesModel.getTribeById(cal.tribeId); if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; } } catch { ctx.redirect('/tribes'); return; } + } else { + const participants = Array.isArray(cal.participants) ? cal.participants : (Array.isArray(cal.members) ? cal.members : []); + const isOpen = String(cal.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && cal.author !== uid && !participants.includes(uid)) { ctx.redirect('/calendars?filter=all'); return; } } if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== uid) { ctx.body = tribeAccessDeniedView(parentTribe); return; @@ -2055,8 +2534,9 @@ router return } const modelFilter = filter === "BACKERS" ? "ALL" : filter - const projects = await projectsModel.listProjects(modelFilter) + let projects = await projectsModel.listProjects(modelFilter) await enrichWithComments(projects) + projects = await applyListFilters(projects, ctx) ctx.body = await projectsView(projects, filter) }) .get("/projects/edit/:id", async (ctx) => { @@ -2070,8 +2550,9 @@ router const projectId = ctx.params.projectId const filter = String(ctx.query.filter || "ALL").toUpperCase() const project = await projectsModel.getProjectById(projectId) - const comments = await getVoteComments(projectId) - ctx.body = await singleProjectView(withCount(project, comments), filter, comments) + const zoom = parseInt(ctx.query.zoom) || 2; + const [comments, mapData] = await Promise.all([getVoteComments(projectId), resolveMapUrl(project.mapUrl)]) + ctx.body = await singleProjectView(withCount(project, comments), filter, comments, { mapData, zoom }) }) .get("/banking", async (ctx) => { if (!checkMod(ctx, 'bankingMod')) { ctx.redirect('/modules'); return; } @@ -2081,7 +2562,17 @@ router const q = (query.q || '').trim(); const msg = (query.msg || '').trim(); await bankingModel.ensureSelfAddressPublished(); + if (bankingModel.isPubNode() && filter === 'overview') { + try { await bankingModel.executeEpoch({}); } catch (_) {} + try { await bankingModel.processPendingClaims(); } catch (_) {} + } const data = await bankingModel.listBanking(filter, userId); + data.isPub = bankingModel.isPubNode(); + data.alreadyClaimed = data.summary?.alreadyClaimed || false; + if (filter === 'overview') { + const pending = (data.allocations || []).find(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED")); + data.pendingUBI = pending || null; + } if (filter === 'addresses' && q) { data.addresses = (data.addresses || []).filter(x => String(x.id).toLowerCase().includes(q.toLowerCase()) || @@ -2090,16 +2581,17 @@ router data.search = q; } data.flash = msg || ''; - const { ecoValue, inflationFactor, ecoInHours, currentSupply, isSynced } = await bankingModel.calculateEcoinValue(); + const { ecoValue, inflationFactor, inflationMonthly, ecoTimeMs, currentSupply, isSynced } = await bankingModel.calculateEcoinValue(); data.exchange = { - ecoValue: ecoValue, + ecoValue, inflationFactor, - ecoInHours, - currentSupply: currentSupply, + inflationMonthly, + ecoTimeMs, + currentSupply, totalSupply: 25500000, - isSynced: isSynced + isSynced }; - ctx.body = renderBankingView(data, filter, userId); + ctx.body = renderBankingView(data, filter, userId, data.isPub); }) .get("/banking/allocation/:id", async (ctx) => { const userId = getViewerId(); @@ -2152,6 +2644,27 @@ router const aiModOn = logsModel.isAImodOn(); ctx.body = logsView([], 'today', entry.mode, { view: 'detail', aiModOn, entry }); }) + .post("/logs/create", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const mode = b.mode === 'ai' ? 'ai' : 'manual'; + try { + if (mode === 'ai') { startAI(); await logsModel.createAI(); } + else await logsModel.createManual(b.label || '', b.text || ''); + } catch (_) {} + ctx.redirect('/logs'); + }) + .post("/logs/edit/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {} + ctx.redirect('/logs'); + }) + .post("/logs/delete/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + try { await logsModel.deleteLog(ctx.params.id); } catch (_) {} + ctx.redirect('/logs'); + }) .get("/logs/export", async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } const items = await logsModel.listLogs('always'); @@ -2174,7 +2687,7 @@ router try { ctx.body = await cipherView(); } catch (error) { - ctx.body = { error: error.message }; + sendErrorPage(ctx, error.message); } }) .get("/thread/:message", async (ctx) => { @@ -2303,8 +2816,7 @@ router .post('/ai', koaBody(), async (ctx) => { const { input } = ctx.request.body; if (!input) { - ctx.status = 400; - ctx.body = { error: 'No input provided' }; + sendErrorPage(ctx, 'No input provided', { status: 400 }); return; } startAI(); @@ -2419,7 +2931,7 @@ router const userPrompt = config.ai?.prompt?.trim() || ''; ctx.body = aiView(chatHistory, userPrompt); }) - .post('/ai/clear', koaBody(), async (ctx) => { + .post('/ai/clear', async (ctx) => { const i18nAll = require('../client/assets/translations/i18n'); const lang = ctx.cookies.get('language') || getConfig().language || 'en'; const { setLanguage } = require('../views/main_views'); @@ -2430,6 +2942,51 @@ router const userPrompt = config.ai?.prompt?.trim() || ''; ctx.body = aiView([], userPrompt); }) + .post('/ai/ask', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'aiNavMod')) { + sendErrorPage(ctx, require('../views/main_views').i18n.aiNavDisabled || 'AI navigation is disabled.', { status: 403 }); + return; + } + const raw = String(ctx.request.body?.q || ctx.request.body?.prompt || '').trim(); + if (!raw) { ctx.redirect('/'); return; } + const ssbRefLib = require('../server/node_modules/ssb-ref'); + const hashtagMatch = raw.match(/^#([\p{L}\p{N}_-]+)/u); + if (hashtagMatch) { + ctx.redirect('/search?query=' + encodeURIComponent('#' + hashtagMatch[1])); + return; + } + const feedMatch = raw.match(/^@?([A-Za-z0-9+/=._-]+\.ed25519)\b/); + if (feedMatch) { + const id = (feedMatch[0].startsWith('@') ? feedMatch[0] : '@' + feedMatch[1]); + if (ssbRefLib.isFeed(id)) { ctx.redirect('/author/' + encodeURIComponent(id)); return; } + } + if (/^https?:\/\//i.test(raw)) { + try { + const u = new URL(raw); + if (u.host === ctx.host) { ctx.redirect(u.pathname + u.search + u.hash); return; } + } catch (_) {} + } + try { + const embedder = require('../AI/embedder'); + const routesIndex = require('../AI/routes_index'); + if (!embedder.isInstalled()) { + ctx.redirect('/search?query=' + encodeURIComponent(raw)); + return; + } + const vec = await embedder.embed(raw); + if (!vec) { + ctx.redirect('/search?query=' + encodeURIComponent(raw)); + return; + } + const isModuleEnabled = (modName) => checkMod(ctx, modName); + const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed }); + if (best && best.path) { + ctx.redirect(best.path); + return; + } + } catch (_) {} + ctx.redirect('/search?query=' + encodeURIComponent(raw)); + }) .post('/pixelia/paint', koaBody(), async (ctx) => { const x = Number(ctx.request.body.x), y = Number(ctx.request.body.y), color = ctx.request.body.color; if (!Number.isFinite(x) || !Number.isFinite(y) || x < 1 || x > 50 || y < 1 || y > 200) { @@ -2441,16 +2998,22 @@ router await pixeliaModel.paintPixel(x, y, color); ctx.redirect('/pixelia'); }) - .post('/games/submit-score', koaBody(), async (ctx) => { - if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; } - const { game, score } = ctx.request.body; - try { await gamesModel.submitScore(game, score); } catch (_) {} - ctx.redirect('/games?filter=scoring'); - }) .post('/pm', koaBody(), async ctx => { const { recipients, subject, text } = ctx.request.body; - const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean); - await pmModel.sendMessage(recipientsArr, subject, text); + const recipientsArr = (recipients || '').split(',').map(s => s.trim()).filter(Boolean).filter(id => ssbRef.isFeedId(id)); + if (recipientsArr.length === 0) { ctx.throw(400, 'No valid recipients'); return; } + const cfgNow = getConfig(); + if (cfgNow.pmVisibility === 'mutuals') { + const viewer = getViewerId(); + for (const rid of recipientsArr) { + if (rid === viewer) continue; + let rel; + try { rel = await friend.getRelationship(rid); } catch (e) { rel = null; } + const mutual = !!(rel && rel.following && rel.followsMe); + if (!mutual) ctx.throw(403, 'You can only send private messages to habitants with mutual support.'); + } + } + await pmModel.sendMessage(recipientsArr, stripDangerousTags(subject), stripDangerousTags(text)); await refreshInboxCount(); ctx.redirect('/inbox?filter=sent'); }) @@ -2469,14 +3032,28 @@ router if (typeof types === "string") types = [types]; if (!Array.isArray(types)) types = []; if (!query) return ctx.body = await searchView({ messages: [], query, types }); + const userId = getViewerId(); + const allTribes = await tribesModel.listAll(); + const anonTribeIds = new Set(allTribes.filter(t => t.isAnonymous === true).map(t => t.id)); + const applySearchPrivacy = (msgs) => msgs.filter(msg => { + const c = msg.value?.content; + if (!c) return true; + if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; + if (c.type === 'event' && c.isPublic === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false; + if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE' && c.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false; + if (c.status === 'PRIVATE') return false; + if (c.type === 'shop' && c.visibility === 'CLOSED' && c.author !== userId) return false; + return true; + }); const results = await searchModel.search({ query, types }); ctx.body = await searchView({ results: Object.entries(results).reduce((acc, [type, msgs]) => { - acc[type] = msgs.map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); + const filtered = applySearchPrivacy(msgs).map(msg => (!msg.value?.content) ? {} : { ...msg, content: msg.value.content, author: msg.value.content.author || 'Unknown' }); + if (filtered.length > 0) acc[type] = filtered; return acc; }, {}), query, types }); }) .post("/subtopic/preview/:message", - koaBody(multipartOpts()), + koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const { message } = ctx.params; const rootMessage = await post.get(message); @@ -2511,7 +3088,7 @@ router ctx.body = await publishSubtopic({ message, text }); ctx.redirect(`/thread/${encodeURIComponent(message)}`); }) - .post("/comment/preview/:message", koaBody(multipartOpts()), async (ctx) => { + .post("/comment/preview/:message", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const { messages, contentWarning, myFeedId, parentMessage } = await resolveCommentComponents(ctx); const previewData = await preparePreview(ctx); ctx.body = await previewCommentView({ @@ -2546,11 +3123,11 @@ router }); ctx.redirect(`/thread/${encodeURIComponent(parent.key)}`); }) - .post("/publish/preview", koaBody({ ...multipartOpts({ multiples: false }), urlencoded: true }), async (ctx) => { + .post("/publish/preview", koaBody({multipart: true, formidable: { multiples: false, maxFileSize: maxSize }, urlencoded: true }), async (ctx) => { const cw = stripDangerousTags(ctx.request.body.contentWarning?.toString().trim() || ""); ctx.body = await previewView({ previewData: await preparePreview(ctx), contentWarning: cw.length > 0 ? cw : undefined }); }) - .post("/publish", koaBody({ ...multipartOpts({ multiples: false }), urlencoded: true }), async (ctx) => { + .post("/publish", koaBody({ multipart: true, urlencoded: true, formidable: { multiples: false, maxFileSize: maxSize } }), async (ctx) => { const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || ""); let mentions = []; try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); } @@ -2560,34 +3137,47 @@ router .post("/publish/custom", koaBody(), async (ctx) => { const text = String(ctx.request.body.text); const obj = JSON.parse(text); + const ALLOWED_TYPES = ['post','about','contact','vote','pub','channel']; + if (!obj.type || !ALLOWED_TYPES.includes(obj.type)) { ctx.throw(400, 'Invalid message type'); return; } + const sanitizeObj = (o) => { for (const k of Object.keys(o)) { if (typeof o[k] === 'string') o[k] = stripDangerousTags(o[k]); else if (o[k] && typeof o[k] === 'object') sanitizeObj(o[k]); } }; + sanitizeObj(obj); ctx.body = await post.publishCustom(obj); ctx.redirect(`/public/latest`); }) .post("/follow/:feed", koaBody(), async (ctx) => { ctx.body = await friend.follow(ctx.params.feed); - ctx.redirect(new URL(ctx.request.header.referer).href); + safeRefererRedirect(ctx, '/inhabitants'); }) .post("/unfollow/:feed", koaBody(), async (ctx) => { ctx.body = await friend.unfollow(ctx.params.feed); - ctx.redirect(new URL(ctx.request.header.referer).href); + safeRefererRedirect(ctx, '/inhabitants'); }) .post("/block/:feed", koaBody(), async (ctx) => { ctx.body = await friend.block(ctx.params.feed); - ctx.redirect(new URL(ctx.request.header.referer).href); + safeRefererRedirect(ctx, '/inhabitants'); }) .post("/unblock/:feed", koaBody(), async (ctx) => { ctx.body = await friend.unblock(ctx.params.feed); - ctx.redirect(new URL(ctx.request.header.referer).href); + safeRefererRedirect(ctx, '/inhabitants'); }) .post("/like/:message", koaBody(), async (ctx) => { const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue); - const referer = new URL(ctx.request.header.referer); - referer.hash = `centered-footer-${encodeURIComponent(message)}`; + const ref = ctx.request.header.referer; + let target = '/public/latest'; + try { + if (ref) { + const u = new URL(ref); + if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) { + u.hash = `centered-footer-${encodeURIComponent(message)}`; + target = u.pathname + u.search + u.hash; + } + } + } catch (_) {} const msgData = await post.get(message); const isPrivate = msgData.value.meta.private === true; const normalized = (isPrivate ? msgData.value.content.recps : []).map(r => typeof r === 'string' ? r : r?.link).filter(Boolean); ctx.body = await vote.publish({ messageKey: message, value: voteValue, recps: normalized.length ? normalized : undefined }); - ctx.redirect(referer.href); + ctx.redirect(target); }) .post('/forum/create', koaBody(), async ctx => { const { category, title, text } = ctx.request.body; @@ -2606,6 +3196,8 @@ router ctx.redirect(ctx.get('referer') || `/forum/${encodeURIComponent(ctx.params.forumId)}`); }) .post('/forum/delete/:id', koaBody(), async ctx => { + const forum = await forumModel.getForumById(ctx.params.id).catch(() => null); + if (!forum || forum.author !== getViewerId()) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; } await forumModel.deleteForumById(ctx.params.id); ctx.redirect('/forum'); }) @@ -2617,7 +3209,13 @@ router ctx.redirect('/legacy'); } catch (error) { ctx.status = 500; ctx.body = { error: `Error: ${error.message}` }; ctx.redirect('/legacy'); } }) - .post('/legacy/import', koaBody(multipartOpts({ keepExtensions: true })), async (ctx) => { + .post('/legacy/import', koaBody({ + multipart: true, + formidable: { + keepExtensions: true, + uploadDir: '/tmp', + } + }), async (ctx) => { const uploadedFile = ctx.request.files?.uploadedFile, pw = ctx.request.body.importPassword; if (!uploadedFile) { ctx.body = { error: 'No file uploaded' }; return ctx.redirect('/legacy'); } if (!pw || pw.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/legacy'); } @@ -2627,24 +3225,24 @@ router ctx.redirect('/legacy'); } catch (error) { ctx.body = { error: error.message }; ctx.redirect('/legacy'); } }) - .post('/trending/:contentId/:category', koaBody(), async (ctx) => { + .post('/trending/:contentId/:category', async (ctx) => { const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id; if ((await trendingModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) { ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/trending'); } await trendingModel.createVote(contentId, category); ctx.redirect('/trending'); }) - .post('/opinions/:contentId/:category', koaBody(), async (ctx) => { + .post('/opinions/:contentId/:category', async (ctx) => { const { contentId, category } = ctx.params, voterId = SSBconfig?.keys?.id; if ((await opinionsModel.getMessageById(contentId))?.content?.opinions_inhabitants?.includes(voterId)) { ctx.flash = { message: 'You have already opined.' }; return ctx.redirect('/opinions'); } await opinionsModel.createVote(contentId, category); ctx.redirect('/opinions'); }) - .post('/agenda/discard/:itemId', koaBody(), async (ctx) => { + .post('/agenda/discard/:itemId', async (ctx) => { await agendaModel.discardItem(ctx.params.itemId); ctx.redirect('/agenda'); }) - .post('/agenda/restore/:itemId', koaBody(), async (ctx) => { + .post('/agenda/restore/:itemId', async (ctx) => { await agendaModel.restoreItem(ctx.params.itemId); ctx.redirect('/agenda?filter=discarded'); }) .post("/feed/create", koaBody(), async (ctx) => { @@ -2653,7 +3251,7 @@ router await feedModel.createFeed(text, mentions); ctx.redirect("/feed?filter=ALL&msg=feedPublished"); }) - .post("/feed/opinions/:feedId/:category", koaBody(), async (ctx) => { + .post("/feed/opinions/:feedId/:category", async (ctx) => { const { feedId, category } = ctx.params; try { await feedModel.addOpinion(feedId, category); @@ -2661,9 +3259,20 @@ router ctx.redirect(ctx.get("Referer") || "/feed"); }) .post("/feed/refeed/:id", koaBody(), async (ctx) => { - await feedModel.createRefeed(ctx.params.id); + try { + await feedModel.createRefeed(ctx.params.id); + } catch (e) { + if (e.message !== "Already refeeded") throw e; + } ctx.redirect(ctx.get("Referer") || "/feed"); }) + .post("/feed/:feedId/comments", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { + const text = ctx.request.body?.text != null ? stripDangerousTags(String(ctx.request.body.text)) : ""; + const imageMarkdown = ctx.request.files?.blob ? await handleBlobUpload(ctx, 'blob') : null; + const fullText = imageMarkdown ? (text ? text + '\n' : '') + imageMarkdown : text; + await feedModel.addComment(ctx.params.feedId, fullText); + ctx.redirect(`/feed/${encodeURIComponent(ctx.params.feedId)}`); + }) .post("/bookmarks/create", koaBody(), async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; @@ -2681,16 +3290,16 @@ router .post("/bookmarks/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'add')) .post("/bookmarks/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'bookmarks', 'remove')) .post("/bookmarks/:bookmarkId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'bookmarks', 'bookmarkId')) - .post("/images/create", koaBody(multipartOpts()), async (ctx) => { + .post("/images/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const blob = await handleBlobUpload(ctx, 'image'), b = ctx.request.body; - await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme)); + await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); ctx.redirect(safeReturnTo(ctx, '/images?filter=all', ['/images'])); }) - .post("/images/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/images/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, blob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme)); + await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); ctx.redirect(safeReturnTo(ctx, '/images?filter=mine', ['/images'])); }) .post("/images/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'images')) @@ -2698,16 +3307,25 @@ router .post("/images/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'images', 'add')) .post("/images/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'images', 'remove')) .post("/images/:imageId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'images', 'imageId')) - .post("/maps/create", koaBody(multipartOpts()), async (ctx) => { + .post("/maps/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; + if (b.tribeId) { + const t = await tribesModel.getTribeById(b.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } const imageId = extractBlobId(await handleBlobUpload(ctx, 'image')) || ""; const newMap = await mapsModel.createMap(b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), b.tribeId || null, stripDangerousTags(b.markerLabel), imageId); const redir = b.tribeId ? `/tribe/${encodeURIComponent(b.tribeId)}?section=maps` : safeReturnTo(ctx, '/maps?filter=all', ['/maps']); ctx.redirect(redir); }) - .post("/maps/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/maps/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } const b = ctx.request.body; const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) || "" : ""; await mapsModel.updateMapById(ctx.params.id, b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), imageId || undefined); @@ -2715,66 +3333,119 @@ router }) .post("/maps/delete/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } await mapsModel.deleteMapById(ctx.params.id); ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps'])); }) .post("/maps/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'add')) .post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove')) - .post("/maps/:mapId/marker", koaBody(multipartOpts()), async (ctx) => { + .post("/maps/generate-invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + try { + const code = await mapsModel.generateInvite(ctx.params.id); + ctx.body = `

Map invite code: ${code}

Back

`; + } catch (e) { + ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); + } + }) + .post("/maps/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || "").trim(); + try { + const mapId = await mapsModel.joinByInvite(code); + ctx.redirect(`/maps/${encodeURIComponent(mapId)}`); + } catch (_) { + ctx.redirect('/maps'); + } + }) + .post("/maps/join/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } + try { await mapsModel.joinMap(ctx.params.id); } catch (_) {} + ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`); + }) + .post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } const uid = getViewerId(); const mapItem = await mapsModel.getMapById(ctx.params.mapId, uid); if (mapItem.tribeId) { try { const t = await tribesModel.getTribeById(mapItem.tribeId); - if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; } - } catch { ctx.status = 403; ctx.body = "Forbidden"; return; } + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const b = ctx.request.body; const imageBlobId = extractBlobId(await handleBlobUpload(ctx, 'image')) || ""; await mapsModel.addMarker(ctx.params.mapId, b.mkLat, b.mkLng, stripDangerousTags(b.label), imageBlobId); ctx.redirect(safeReturnTo(ctx, `/maps/${encodeURIComponent(ctx.params.mapId)}`, ['/maps'])); }) - .post("/audios/create", koaBody(multipartOpts()), async ctx => mediaCreateAction(ctx, 'audios')) - .post("/audios/update/:id", koaBody(multipartOpts()), async ctx => mediaUpdateAction(ctx, 'audios')) + .post("/audios/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'audios')) + .post("/audios/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'audios')) .post("/audios/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'audios')) .post("/audios/opinions/:audioId/:category", koaBody(), async ctx => opinionAction(ctx, 'audios', 'audioId')) .post("/audios/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'add')) .post("/audios/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'audios', 'remove')) .post("/audios/:audioId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'audios', 'audioId')) - .post("/torrents/create", koaBody(multipartOpts()), async (ctx) => { + .post("/torrents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const { tags, title, description, tribeId } = ctx.request.body; + const cleanTribeId = tribeId ? String(tribeId).trim() : null; + if (cleanTribeId) { + const t = await tribesModel.getTribeById(cleanTribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } const blob = await handleBlobUpload(ctx, 'torrent'); const fileSize = ctx.request.files?.torrent?.size || 0; - const { tags, title, description } = ctx.request.body; - await torrentsModel.createTorrent(blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), fileSize); - ctx.redirect(safeReturnTo(ctx, '/torrents?filter=all', ['/torrents'])); + await torrentsModel.createTorrent(blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), fileSize, cleanTribeId); + ctx.redirect(cleanTribeId ? `/tribe/${encodeURIComponent(cleanTribeId)}?section=torrents` : safeReturnTo(ctx, '/torrents?filter=all', ['/torrents'])); }) - .post("/torrents/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/torrents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } + const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } const { tags, title, description } = ctx.request.body; const blob = ctx.request.files?.torrent ? await handleBlobUpload(ctx, 'torrent') : null; await torrentsModel.updateTorrentById(ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description)); ctx.redirect(safeReturnTo(ctx, '/torrents?filter=mine', ['/torrents'])); }) - .post("/torrents/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'torrents')) - .post("/torrents/opinions/:torrentId/:category", koaBody(), async ctx => opinionAction(ctx, 'torrents', 'torrentId')) + .post("/torrents/delete/:id", koaBody(), async ctx => { + const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + return deleteAction(ctx, 'torrents'); + }) + .post("/torrents/opinions/:torrentId/:category", koaBody(), async ctx => { + const target = await torrentsModel.getTorrentById(ctx.params.torrentId, getViewerId()).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + return opinionAction(ctx, 'torrents', 'torrentId'); + }) .post("/torrents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'add')) .post("/torrents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'remove')) .post("/torrents/:torrentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'torrents', 'torrentId')) - .post("/videos/create", koaBody(multipartOpts()), async ctx => mediaCreateAction(ctx, 'videos')) - .post("/videos/update/:id", koaBody(multipartOpts()), async ctx => mediaUpdateAction(ctx, 'videos')) + .post("/videos/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaCreateAction(ctx, 'videos')) + .post("/videos/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => mediaUpdateAction(ctx, 'videos')) .post("/videos/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'videos')) .post("/videos/opinions/:videoId/:category", koaBody(), async ctx => opinionAction(ctx, 'videos', 'videoId')) .post("/videos/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'add')) .post("/videos/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'videos', 'remove')) .post("/videos/:videoId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'videos', 'videoId')) - .post("/documents/create", koaBody(multipartOpts()), async (ctx) => { + .post("/documents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const docBlob = await handleBlobUpload(ctx, "document"), b = ctx.request.body; await documentsModel.createDocument(docBlob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description)); ctx.redirect(safeReturnTo(ctx, "/documents?filter=all", ["/documents"])); }) - .post("/documents/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/documents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const b = ctx.request.body, blob = ctx.request.files?.document ? await handleBlobUpload(ctx, "document") : null; await documentsModel.updateDocumentById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description)); ctx.redirect(safeReturnTo(ctx, "/documents?filter=mine", ["/documents"])); @@ -2784,17 +3455,17 @@ router .post("/documents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'add')) .post("/documents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'documents', 'remove')) .post("/documents/:documentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'documents', 'documentId')) - .post('/cv/upload', koaBody(multipartOpts()), async ctx => { + .post('/cv/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const photoUrl = await handleBlobUpload(ctx, 'image') await cvModel.createCV(ctx.request.body, photoUrl) ctx.redirect('/cv') }) - .post('/cv/update/:id', koaBody(multipartOpts()), async ctx => { + .post('/cv/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const photoUrl = await handleBlobUpload(ctx, 'image') await cvModel.updateCV(ctx.params.id, ctx.request.body, photoUrl) ctx.redirect('/cv') }) - .post('/cv/delete/:id', koaBody(), async ctx => { + .post('/cv/delete/:id', async ctx => { await cvModel.deleteCVById(ctx.params.id) ctx.redirect('/cv') }) @@ -2809,16 +3480,17 @@ router if (password.length < 32) { ctx.body = { error: 'Password is too short or missing.' }; return ctx.redirect('/cipher'); } ctx.body = await cipherView("", cipherModel.decryptData(encryptedText, password), "", password); }) - .post('/tribes/create', koaBody(multipartOpts()), async ctx => { + .post('/tribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; } const image = await handleBlobUpload(ctx, 'image'); - await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode); + const tribeRes = await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode, null, 'OPEN', stripDangerousTags(b.mapUrl)); + try { if (tribeRes?.key) await parliamentModel.tribe.publishInitialTerm(tribeRes.key); } catch (e) { console.error('publishInitialTerm failed:', e); } ctx.redirect('/tribes'); }) - .post('/tribe/:id/subtribes/create', koaBody(multipartOpts()), async ctx => { + .post('/tribe/:id/subtribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } const parentTribe = await tribesModel.getTribeById(ctx.params.id); const viewerId = getViewerId(); @@ -2829,10 +3501,12 @@ router const b = ctx.request.body; if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; const image = await handleBlobUpload(ctx, 'image'); - await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode || 'open', ctx.params.id); + const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false })); + const effectiveAnonymous = !!(parentEffective.isPrivate || parentTribe.isAnonymous); + await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, false, effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl)); ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`); }) - .post('/tribes/update/:id', koaBody(multipartOpts()), async ctx => { + .post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } const tribe = await tribesModel.getTribeById(ctx.params.id); if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; } @@ -2840,10 +3514,19 @@ router if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return; if (b.inviteMode && !['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; } const tags = b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []; - await tribesModel.updateTribeById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, isLARP: b.isLARP === 'true', isAnonymous: b.isAnonymous === 'true', inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' }); + const isSub = !!tribe.parentTribeId; + const updateFields = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' }; + if (isSub) { + updateFields.isLARP = false; + updateFields.isAnonymous = !!tribe.isAnonymous; + } else { + updateFields.isLARP = b.isLARP === 'true'; + updateFields.isAnonymous = b.isAnonymous === 'true'; + } + await tribesModel.updateTribeById(ctx.params.id, updateFields); ctx.redirect('/tribes?filter=mine'); }) - .post('/tribes/delete/:id', koaBody(), async ctx => { + .post('/tribes/delete/:id', async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } const tribe = await tribesModel.getTribeById(ctx.params.id); if (tribe.author !== getViewerId()) { ctx.status = 403; ctx.redirect('/tribes'); return; } @@ -2980,7 +3663,7 @@ router const thread = ctx.query.thread || ''; ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=forum${thread ? '&thread=' + encodeURIComponent(thread) : ''}`); }) - .post('/tribe/:id/media/upload', koaBody(multipartOpts()), async ctx => { + .post('/tribe/:id/media/upload', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; } const tribe = await tribesModel.getTribeById(ctx.params.id); if (!tribe.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } @@ -3022,12 +3705,12 @@ router const { exec } = require('child_process'); try { await panicmodeModel.removeSSB(); - ctx.body = { message: 'Your blockchain has been succesfully deleted!' }; + sendErrorPage(ctx, 'Your blockchain has been successfully deleted!'); exec('pkill -f "node SSB_server.js start"'); setTimeout(() => process.exit(0), 1000); - } catch (error) { ctx.body = { error: 'Error deleting your blockchain: ' + error.message }; } + } catch (error) { sendErrorPage(ctx, 'Error deleting your blockchain: ' + error.message); } }) - .post('/export/create', koaBody(), async (ctx) => { + .post('/export/create', async (ctx) => { try { const outputPath = path.join(os.homedir(), 'ssb_exported.zip'); await exportmodeModel.exportSSB(outputPath); @@ -3035,16 +3718,22 @@ router ctx.set('Content-Disposition', `attachment; filename=ssb_exported.zip`); ctx.body = fs.createReadStream(outputPath); ctx.res.on('finish', () => fs.unlinkSync(outputPath)); - } catch (error) { ctx.body = { error: 'Error exporting your blockchain: ' + error.message }; } + } catch (error) { sendErrorPage(ctx, 'Error exporting your blockchain: ' + error.message); } }) - .post('/tasks/create', koaBody(), async ctx => { + .post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const b = ctx.request.body; - await tasksModel.createTask(stripDangerousTags(b.title), stripDangerousTags(b.description), b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic); + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await tasksModel.createTask(stripDangerousTags(b.title), desc, b.startTime, b.endTime, b.priority, stripDangerousTags(b.location), b.tags, b.isPublic); ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); }) - .post('/tasks/update/:id', koaBody(), async ctx => { + .post('/tasks/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const b = ctx.request.body, tags = Array.isArray(b.tags) ? b.tags.filter(Boolean) : (typeof b.tags === 'string' ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : []); - await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic }); + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await tasksModel.updateTaskById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, startTime: b.startTime, endTime: b.endTime, priority: b.priority, location: stripDangerousTags(b.location), tags, isPublic: b.isPublic }); ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); }) .post('/tasks/assign/:id', koaBody(), async ctx => { @@ -3060,7 +3749,7 @@ router ctx.redirect(safeReturnTo(ctx, '/tasks?filter=mine', ['/tasks'])); }) .post('/tasks/:taskId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'tasks', 'taskId')) - .post('/reports/create', koaBody(multipartOpts()), async ctx => { + .post('/reports/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image'); await reportsModel.createReport(stripDangerousTags(b.title), stripDangerousTags(b.description), b.category, image, b.tags, b.severity, { stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate, @@ -3070,24 +3759,24 @@ router }); ctx.redirect('/reports'); }) - .post('/reports/update/:id', koaBody(multipartOpts()), async ctx => { + .post('/reports/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => { const b = ctx.request.body, image = await handleBlobUpload(ctx, 'image'); await reportsModel.updateReportById(ctx.params.id, { - title: b.title, description: b.description, category: b.category, image, tags: b.tags, severity: b.severity, + title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), category: b.category, image, tags: b.tags, severity: b.severity, template: { - stepsToReproduce: b.stepsToReproduce, expectedBehavior: b.expectedBehavior, actualBehavior: b.actualBehavior, environment: b.environment, reproduceRate: b.reproduceRate, - problemStatement: b.problemStatement, userStory: b.userStory, acceptanceCriteria: b.acceptanceCriteria, - whatHappened: b.whatHappened, reportedUser: b.reportedUser, evidenceLinks: b.evidenceLinks, - contentLocation: b.contentLocation, whyInappropriate: b.whyInappropriate, requestedAction: b.requestedAction + stepsToReproduce: stripDangerousTags(b.stepsToReproduce), expectedBehavior: stripDangerousTags(b.expectedBehavior), actualBehavior: stripDangerousTags(b.actualBehavior), environment: stripDangerousTags(b.environment), reproduceRate: b.reproduceRate, + problemStatement: stripDangerousTags(b.problemStatement), userStory: stripDangerousTags(b.userStory), acceptanceCriteria: stripDangerousTags(b.acceptanceCriteria), + whatHappened: stripDangerousTags(b.whatHappened), reportedUser: stripDangerousTags(b.reportedUser), evidenceLinks: stripDangerousTags(b.evidenceLinks), + contentLocation: stripDangerousTags(b.contentLocation), whyInappropriate: stripDangerousTags(b.whyInappropriate), requestedAction: stripDangerousTags(b.requestedAction) } }); ctx.redirect('/reports?filter=mine'); }) - .post('/reports/delete/:id', koaBody(), async ctx => { + .post('/reports/delete/:id', async ctx => { await reportsModel.deleteReportById(ctx.params.id); ctx.redirect('/reports?filter=mine'); }) - .post('/reports/confirm/:id', koaBody(), async ctx => { + .post('/reports/confirm/:id', async ctx => { await reportsModel.confirmReportById(ctx.params.id); ctx.redirect('/reports'); }) @@ -3096,14 +3785,20 @@ router ctx.redirect('/reports?filter=mine'); }) .post('/reports/:reportId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'reports', 'reportId')) - .post('/events/create', koaBody(), async (ctx) => { + .post('/events/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const b = ctx.request.body; - await eventsModel.createEvent(stripDangerousTags(b.title), stripDangerousTags(b.description), b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic); - ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await eventsModel.createEvent(stripDangerousTags(b.title), desc, b.date, stripDangerousTags(b.location), b.price, b.url, b.attendees || [], b.tags, b.isPublic, stripDangerousTags(b.mapUrl)); + ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); }) - .post('/events/update/:id', koaBody(), async (ctx) => { + .post('/events/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const b = ctx.request.body, existing = await eventsModel.getEventById(ctx.params.id); - await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer }); + const imageMarkdown = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; + let desc = stripDangerousTags(b.description); + if (imageMarkdown) desc = (desc ? desc + '\n' : '') + imageMarkdown; + await eventsModel.updateEventById(ctx.params.id, { title: stripDangerousTags(b.title), description: desc, date: b.date, location: stripDangerousTags(b.location), price: b.price, url: b.url, attendees: b.attendees, tags: b.tags, isPublic: b.isPublic, createdAt: existing.createdAt, organizer: existing.organizer, mapUrl: stripDangerousTags(b.mapUrl) }); ctx.redirect(safeReturnTo(ctx, '/events?filter=mine', ['/events'])); }) .post('/events/attend/:id', koaBody(), async ctx => { @@ -3147,6 +3842,77 @@ router await parliamentModel.proposeCandidature({ candidateId: id, method: m }).catch(e => ctx.throw(400, String(e?.message || e))); ctx.redirect('/parliament?filter=candidatures'); }) + .post('/tribe/:id/governance/publish-candidature', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member'); + const globalTerm = await parliamentModel.getCurrentTerm().catch(() => null); + const already = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribeId, globalTerm?.startAt).catch(() => false); + if (already) ctx.throw(400, 'This tribe already has an open candidature in the current global parliament cycle.'); + const term = await parliamentModel.tribe.getCurrentTerm(tribeId).catch(() => null); + const rawMethod = (term?.method && String(term.method).toUpperCase()) || 'DEMOCRACY'; + const method = rawMethod === 'ANARCHY' ? 'DEMOCRACY' : rawMethod; + await parliamentModel.proposeCandidature({ candidateId: tribeId, method }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect('/parliament?filter=candidatures'); + }) + .post('/tribe/:id/governance/candidature/propose', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member'); + const b = ctx.request.body || {}; + const candidateId = String(b.candidateId || '').trim(); + const method = String(b.method || '').trim().toUpperCase(); + if (!candidateId) ctx.throw(400, 'Candidate required'); + await parliamentModel.tribe.publishTribeCandidature({ tribeId, candidateId, method }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`); + }) + .post('/tribe/:id/governance/candidature/vote', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + const isCreator = tribe.author === uid; + const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid); + if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member'); + const candidatureId = String(ctx.request.body?.candidatureId || '').trim(); + if (!candidatureId) ctx.throw(400, 'Missing candidatureId'); + await parliamentModel.tribe.voteTribeCandidature({ tribeId, candidatureId }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=candidatures`); + }) + .post('/tribe/:id/governance/rule/add', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can add rules'); + const b = ctx.request.body || {}; + await parliamentModel.tribe.publishTribeRule({ tribeId, title: stripDangerousTags(String(b.title || '')), body: stripDangerousTags(String(b.body || '')) }).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`); + }) + .post('/tribe/:id/governance/rule/delete', koaBody(), async (ctx) => { + const tribeId = ctx.params.id; + const uid = getViewerId(); + const tribe = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!tribe) ctx.throw(404, 'Tribe not found'); + if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance'); + if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can delete rules'); + const ruleId = String(ctx.request.body?.ruleId || '').trim(); + if (!ruleId) ctx.throw(400, 'Missing ruleId'); + await parliamentModel.tribe.deleteTribeRule(ruleId).catch(e => ctx.throw(400, String(e?.message || e))); + ctx.redirect(`/tribe/${encodeURIComponent(tribeId)}?section=governance&filter=rules`); + }) .post('/parliament/candidatures/:id/vote', koaBody(), async (ctx) => { await parliamentModel.voteCandidature(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e))); ctx.redirect('/parliament?filter=candidatures'); @@ -3159,6 +3925,8 @@ router ctx.redirect('/parliament?filter=proposals'); }) .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => { + const canClose = await parliamentModel.canPropose(); + if (!canClose) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; } await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e))); ctx.redirect('/parliament?filter=proposals'); }) @@ -3178,12 +3946,13 @@ router const respondent = String(b.respondentId || '').trim(), method = String(b.method || '').trim().toUpperCase(); if (!titleSuffix && !titlePreset) { ctx.flash = { message: 'Title is required.' }; return ctx.redirect('/courts?filter=cases'); } if (!respondent) { ctx.flash = { message: 'Accused / Respondent is required.' }; return ctx.redirect('/courts?filter=cases'); } + if (!/^@[A-Za-z0-9+/]+=*\.ed25519$/.test(respondent)) { ctx.flash = { message: 'Invalid respondent ID. Must be a valid SSB ID (@...ed25519).' }; return ctx.redirect('/courts?filter=cases'); } if (!new Set(['JUDGE','DICTATOR','POPULAR','MEDIATION','KARMATOCRACY']).has(method)) { ctx.flash = { message: 'Invalid resolution method.' }; return ctx.redirect('/courts?filter=cases'); } try { await courtsModel.openCase({ titleBase: [titlePreset, titleSuffix].filter(Boolean).join(' - '), respondentInput: respondent, method }); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } ctx.redirect('/courts?filter=mycases'); }) - .post('/courts/cases/:id/evidence/add', koaBody(multipartOpts()), async (ctx) => { + .post('/courts/cases/:id/evidence/add', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { const caseId = ctx.params.id, b = ctx.request.body || {}; if (!caseId) { ctx.flash = { message: 'Case not found.' }; return ctx.redirect('/courts?filter=cases'); } try { await courtsModel.addEvidence({ caseId, text: stripDangerousTags(String(b.text || '')), link: String(b.link || ''), imageMarkdown: ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null }); } @@ -3268,20 +4037,20 @@ router try { await courtsModel.voteNomination(ctx.params.id); } catch (e) { ctx.flash = { message: String(e?.message || e) }; } ctx.redirect('/courts?filter=judges'); }) - .post("/market/create", koaBody(multipartOpts()), async (ctx) => { + .post("/market/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, image = await handleBlobUpload(ctx, "image"), parsedStock = parseInt(String(b.stock || "0"), 10); if (!parsedStock || parsedStock <= 0) ctx.throw(400, "Stock must be a positive number."); const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping); - await marketModel.createItem(b.item_type, stripDangerousTags(b.title), stripDangerousTags(b.description), image, b.price, b.tags, b.item_status, b.deadline, shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", parsedStock); + await marketModel.createItem(b.item_type, stripDangerousTags(b.title), stripDangerousTags(b.description), image, b.price, b.tags, b.item_status, b.deadline, shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", parsedStock, stripDangerousTags(b.mapUrl)); ctx.redirect(safeReturnTo(ctx, "/market", ["/market"])); }) - .post("/market/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/market/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, parsedStock = parseInt(String(b.stock || "0"), 10); if (parsedStock < 0) ctx.throw(400, "Stock cannot be negative."); const pickLast = v => Array.isArray(v) ? v[v.length - 1] : v, shpVal = pickLast(b.includesShipping); - const updatedData = { item_type: b.item_type, title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, item_status: b.item_status, deadline: b.deadline, includesShipping: shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", tags: String(b.tags || "").split(",").map(t => t.trim()).filter(Boolean), stock: parsedStock }; + const updatedData = { item_type: b.item_type, title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, item_status: b.item_status, deadline: b.deadline, includesShipping: shpVal === "1" || shpVal === "on" || shpVal === true || shpVal === "true", tags: String(b.tags || "").split(",").map(t => t.trim()).filter(Boolean), stock: parsedStock, mapUrl: stripDangerousTags(b.mapUrl) }; const image = await handleBlobUpload(ctx, "image"); if (image) updatedData.image = image; await marketModel.updateItemById(ctx.params.id, updatedData); @@ -3308,6 +4077,9 @@ router await pmModel.sendMessage([item.seller], "MARKET_SOLD", `item "${item.title}" has been sold -> /market/${ctx.params.id} OASIS ID: ${getViewerId()} for: ${item.price} ECO`); await marketModel.setItemAsSold(ctx.params.id); } else await marketModel.decrementStock(ctx.params.id); + if (item.shopProductId && checkMod(ctx, 'shopsMod')) { + try { await shopsModel.buyProduct(item.shopProductId); } catch (_) {} + } ctx.redirect(safeReturnTo(ctx, "/inbox?filter=sent", ["/inbox", "/market"])); }) .post("/market/status/:id", koaBody(), async (ctx) => { @@ -3331,16 +4103,16 @@ router ctx.redirect(safeReturnTo(ctx, "/market?filter=auctions", ["/market"])) }) .post("/market/:itemId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'market', 'itemId')) - .post('/jobs/create', koaBody(multipartOpts()), async (ctx) => { + .post('/jobs/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - await jobsModel.createJob({ job_type: b.job_type, title: b.title, description: b.description, requirements: b.requirements, languages: b.languages, job_time: b.job_time, tasks: b.tasks, location: b.location, vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, tags: b.tags, image: imageBlob }); + await jobsModel.createJob({ job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), vacants: b.vacants ? parseInt(b.vacants, 10) : 1, salary: b.salary != null && b.salary !== '' ? parseFloat(String(b.salary).replace(',', '.')) : 0, tags: b.tags, image: imageBlob, mapUrl: stripDangerousTags(b.mapUrl) }); ctx.redirect(safeReturnTo(ctx, '/jobs?filter=MINE', ['/jobs'])); }) - .post('/jobs/update/:id', koaBody(multipartOpts()), async (ctx) => { + .post('/jobs/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined; - const patch = { job_type: b.job_type, title: b.title, description: b.description, requirements: b.requirements, languages: b.languages, job_time: b.job_time, tasks: b.tasks, location: b.location, tags: b.tags }; + const patch = { job_type: stripDangerousTags(b.job_type), title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), requirements: stripDangerousTags(b.requirements), languages: stripDangerousTags(b.languages), job_time: b.job_time, tasks: stripDangerousTags(b.tasks), location: stripDangerousTags(b.location), tags: b.tags, mapUrl: stripDangerousTags(b.mapUrl) }; if (b.vacants !== undefined && b.vacants !== '') patch.vacants = parseInt(b.vacants, 10); if (b.salary !== undefined && b.salary !== '') patch.salary = parseFloat(String(b.salary).replace(',', '.')); if (imageBlob !== undefined) patch.image = imageBlob; @@ -3372,13 +4144,13 @@ router ctx.redirect(safeReturnTo(ctx, '/jobs', ['/jobs'])); }) .post('/jobs/:jobId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'jobs', 'jobId')) - .post("/shops/create", koaBody(multipartOpts()), async (ctx) => { + .post("/shops/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; await shopsModel.createShop(stripDangerousTags(b.title), stripDangerousTags(b.shortDescription), stripDangerousTags(b.description), imageBlob, stripDangerousTags(b.url), stripDangerousTags(b.location), b.tags, b.visibility, stripDangerousTags(b.mapUrl)); ctx.redirect(safeReturnTo(ctx, '/shops?filter=mine', ['/shops'])); }) - .post("/shops/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/shops/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined; const patch = { title: stripDangerousTags(b.title), shortDescription: stripDangerousTags(b.shortDescription), description: stripDangerousTags(b.description), url: stripDangerousTags(b.url), location: stripDangerousTags(b.location), tags: b.tags, visibility: b.visibility, mapUrl: stripDangerousTags(b.mapUrl) }; @@ -3403,7 +4175,7 @@ router await shopsModel.createOpinion(ctx.params.shopId, ctx.params.category); ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(ctx.params.shopId)}`, ['/shops'])); }) - .post("/shops/product/create", koaBody(multipartOpts()), async (ctx) => { + .post("/shops/product/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; const productMsg = await shopsModel.createProduct(b.shopId, stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.price, b.stock, [].concat(b.featured).includes("1")); @@ -3417,7 +4189,7 @@ router } ctx.redirect(safeReturnTo(ctx, `/shops/${encodeURIComponent(b.shopId)}`, ['/shops'])); }) - .post("/shops/product/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/shops/product/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined; const patch = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), price: b.price, stock: b.stock, featured: [].concat(b.featured).includes("1") }; @@ -3445,16 +4217,20 @@ router ctx.redirect(safeReturnTo(ctx, `/shops/product/${encodeURIComponent(ctx.params.productId)}`, ['/shops'])); }) .post("/shops/:shopId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'shops', 'shopId')) - .post("/chats/create", koaBody(multipartOpts()), async (ctx) => { + .post("/chats/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; const tribeId = b.tribeId || null; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); + } const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; - if (tribeId) await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId); ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats'])); }) - .post("/chats/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/chats/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : undefined; @@ -3491,6 +4267,19 @@ router }) .post("/chats/join/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } + const uid = getViewerId(); + const chat = await chatsModel.getChatById(ctx.params.id); + if (!chat) { ctx.status = 404; ctx.body = "Chat not found"; return; } + if (chat.status === "CLOSED") { sendErrorPage(ctx, "Chat is closed", { status: 403 }); return; } + if (chat.status === "INVITE-ONLY" && !chat.members.includes(uid) && chat.author !== uid) { sendErrorPage(ctx, "Invite-only chat", { status: 403 }); return; } + if (chat.tribeId) { + try { + const t = await tribesModel.getTribeById(chat.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats'])); + return; + } try { await chatsModel.joinChat(ctx.params.id); } catch (_) {} @@ -3512,8 +4301,8 @@ router if (chat && chat.tribeId) { try { const t = await tribesModel.getTribeById(chat.tribeId); - if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; } - } catch { ctx.status = 403; ctx.body = "Forbidden"; return; } + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const text = stripDangerousTags(String(ctx.request.body.text || '').trim()); const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null; @@ -3525,7 +4314,11 @@ router if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; const tribeId = b.tribeId || null; - if (tribeId) await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {}); + } const msg = await padsModel.createPad( stripDangerousTags(b.title || ""), b.status || "OPEN", @@ -3574,6 +4367,18 @@ router .post("/pads/join/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } const uid = getViewerId(); + const pad = await padsModel.getPadById(ctx.params.id); + if (!pad) { ctx.status = 404; ctx.body = "Pad not found"; return; } + if (pad.isClosed || pad.status === "CLOSED") { sendErrorPage(ctx, "Pad is closed", { status: 403 }); return; } + if (pad.status === "INVITE-ONLY" && !pad.members.includes(uid) && pad.author !== uid) { sendErrorPage(ctx, "Invite-only pad", { status: 403 }); return; } + if (pad.tribeId) { + try { + const t = await tribesModel.getTribeById(pad.tribeId); + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); + return; + } await padsModel.addMemberToPad(ctx.params.id, uid); ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`); }) @@ -3584,8 +4389,8 @@ router if (pad && pad.tribeId) { try { const t = await tribesModel.getTribeById(pad.tribeId); - if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; } - } catch { ctx.status = 403; ctx.body = "Forbidden"; return; } + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const b = ctx.request.body || {}; const text = stripDangerousTags(String(b.text || "").trim()); @@ -3598,6 +4403,10 @@ router if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; const tribeId = b.tribeId || null; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } const intervalWeekly = [].concat(b.intervalWeekly).includes("1"); const intervalMonthly = [].concat(b.intervalMonthly).includes("1"); const intervalYearly = [].concat(b.intervalYearly).includes("1"); @@ -3621,6 +4430,11 @@ router }) .post("/calendars/update/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } const b = ctx.request.body || {}; try { await calendarsModel.updateCalendarById(ctx.params.id, { @@ -3634,16 +4448,51 @@ router }) .post("/calendars/delete/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } - try { await calendarsModel.deleteCalendarById(ctx.params.id); } catch (_) {} - ctx.redirect('/calendars'); + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + const tribeId = target && target.tribeId; + if (tribeId) { + const t = await tribesModel.getTribeById(tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + await calendarsModel.deleteCalendarById(ctx.params.id); + ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars'); }) .post("/calendars/join/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {} ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); }) + .post("/calendars/generate-invite/:id", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + try { + const code = await calendarsModel.generateInvite(ctx.params.id); + ctx.body = renderCalendarInvitePage(code); + } catch (e) { + ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); + } + }) + .post("/calendars/join-code", koaBody(), async (ctx) => { + if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const code = String((ctx.request.body || {}).code || "").trim(); + try { + const calId = await calendarsModel.joinByInvite(code); + ctx.redirect(`/calendars/${encodeURIComponent(calId)}`); + } catch (_) { + ctx.redirect('/calendars'); + } + }) .post("/calendars/leave/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } + const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } try { await calendarsModel.leaveCalendar(ctx.params.id); } catch (_) {} ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`); }) @@ -3654,8 +4503,8 @@ router if (calForGate && calForGate.tribeId) { try { const t = await tribesModel.getTribeById(calForGate.tribeId); - if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; } - } catch { ctx.status = 403; ctx.body = "Forbidden"; return; } + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const b = ctx.request.body || {}; const intervalWeekly = [].concat(b.intervalWeekly).includes("1"); @@ -3683,8 +4532,8 @@ router if (calForGate && calForGate.tribeId) { try { const t = await tribesModel.getTribeById(calForGate.tribeId); - if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; } - } catch { ctx.status = 403; ctx.body = "Forbidden"; return; } + if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } + } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; } } const b = ctx.request.body || {}; const text = stripDangerousTags(String(b.text || "").trim()); @@ -3696,51 +4545,44 @@ router .post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } const calendarId = (ctx.request.body || {}).calendarId || ""; + if (calendarId) { + const target = await calendarsModel.getCalendarById(calendarId).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + } try { await calendarsModel.deleteNote(ctx.params.noteId); } catch (_) {} ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars'); }) .post("/calendars/delete-date/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; } const calendarId = (ctx.request.body || {}).calendarId || ""; + if (calendarId) { + const target = await calendarsModel.getCalendarById(calendarId).catch(() => null); + if (target && target.tribeId) { + const t = await tribesModel.getTribeById(target.tribeId).catch(() => null); + if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; } + } + } try { await calendarsModel.deleteDate(ctx.params.id, calendarId); } catch (_) {} ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars'); }) .post("/calendars/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'add')) .post("/calendars/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'calendars', 'remove')) - .post("/logs/create", koaBody(), async (ctx) => { - if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } - const b = ctx.request.body || {}; - const mode = b.mode === 'ai' ? 'ai' : 'manual'; - try { - if (mode === 'ai') { startAI(); await logsModel.createAI(); } - else await logsModel.createManual(b.label || '', b.text || ''); - } catch (_) {} - ctx.redirect('/logs'); - }) - .post("/logs/edit/:id", koaBody(), async (ctx) => { - if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } - const b = ctx.request.body || {}; - try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {} - ctx.redirect('/logs'); - }) - .post("/logs/delete/:id", koaBody(), async (ctx) => { - if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } - try { await logsModel.deleteLog(ctx.params.id); } catch (_) {} - ctx.redirect('/logs'); - }) - .post("/projects/create", koaBody(multipartOpts()), async (ctx) => { + .post("/projects/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}, image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : null; const bounties = b.bountiesInput ? String(b.bountiesInput).split("\n").filter(Boolean).map(l => { const [t,a,d] = String(l).split("|"); return { title: String(t||"").trim(), amount: parseFloat(a||0)||0, description: String(d||"").trim(), milestoneIndex: null }; }) : []; - await projectsModel.createProject({ title: b.title, description: b.description, goal: b.goal != null && b.goal !== "" ? parseFloat(b.goal) : 0, deadline: b.deadline ? new Date(b.deadline).toISOString() : null, progress: b.progress != null && b.progress !== "" ? parseInt(b.progress,10) : 0, bounties, image, milestoneTitle: b.milestoneTitle, milestoneDescription: b.milestoneDescription, milestoneTargetPercent: b.milestoneTargetPercent, milestoneDueDate: b.milestoneDueDate }); + await projectsModel.createProject({ title: b.title, description: b.description, goal: b.goal != null && b.goal !== "" ? parseFloat(b.goal) : 0, deadline: b.deadline ? new Date(b.deadline).toISOString() : null, progress: b.progress != null && b.progress !== "" ? parseInt(b.progress,10) : 0, bounties, image, milestoneTitle: b.milestoneTitle, milestoneDescription: b.milestoneDescription, milestoneTargetPercent: b.milestoneTargetPercent, milestoneDueDate: b.milestoneDueDate, mapUrl: stripDangerousTags(b.mapUrl) }); ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"])); }) - .post("/projects/update/:id", koaBody(multipartOpts()), async (ctx) => { + .post("/projects/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } const id = await projectsModel.getProjectTipId(ctx.params.id), b = ctx.request.body || {}; const image = ctx.request.files?.image ? await handleBlobUpload(ctx, "image") : undefined; const bounties = b.bountiesInput !== undefined ? String(b.bountiesInput).split("\n").filter(Boolean).map(l => { const [t,a,d] = String(l).split("|"); return { title: String(t||"").trim(), amount: parseFloat(a||0)||0, description: String(d||"").trim(), milestoneIndex: null }; }) : undefined; - await projectsModel.updateProject(id, { title: b.title, description: b.description, goal: b.goal !== "" && b.goal != null ? parseFloat(b.goal) : undefined, deadline: b.deadline ? new Date(b.deadline).toISOString() : undefined, progress: b.progress !== "" && b.progress != null ? parseInt(b.progress,10) : undefined, bounties, image }); + await projectsModel.updateProject(id, { title: b.title, description: b.description, goal: b.goal !== "" && b.goal != null ? parseFloat(b.goal) : undefined, deadline: b.deadline ? new Date(b.deadline).toISOString() : undefined, progress: b.progress !== "" && b.progress != null ? parseInt(b.progress,10) : undefined, bounties, image, mapUrl: stripDangerousTags(b.mapUrl) }); ctx.redirect(safeReturnTo(ctx, "/projects?filter=MINE", ["/projects"])); }) .post("/projects/delete/:id", koaBody(), async (ctx) => { @@ -3852,30 +4694,51 @@ router ctx.redirect(safeReturnTo(ctx, `/projects/${encodeURIComponent(id)}`, ["/projects"])); }) .post("/projects/:projectId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'projects', 'projectId')) + .post("/banking/claim-ubi", koaBody(), async (ctx) => { + const userId = getViewerId(); + try { + await bankingModel.claimUBI(userId); + ctx.redirect("/banking?filter=overview&msg=claimed_pending"); + } catch (e) { + ctx.redirect(`/banking?filter=overview&msg=${encodeURIComponent(e.message || "error")}`); + } + }) .post("/banking/claim/:id", koaBody(), async (ctx) => { + const { i18n: _i18n } = require("../views/main_views"); const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id); - if (!allocation) { ctx.body = { error: i18n.errorNoAllocation }; return; } - if (allocation.to !== userId || allocation.status !== "UNCONFIRMED") { ctx.body = { error: i18n.errorInvalidClaim }; return; } - const { url, user, pass } = getConfig().walletPub; - const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId, pubWalletUrl: url, pubWalletUser: user, pubWalletPass: pass }); - await bankingModel.updateAllocationStatus(ctx.params.id, "CLOSED", txid); - await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.epochId, allocationId: allocation.id, txid }); + if (!allocation) { sendErrorPage(ctx, _i18n.errorNoAllocation); return; } + if (allocation.to !== userId || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) { sendErrorPage(ctx, _i18n.errorInvalidClaim); return; } + if (!bankingModel.isPubNode()) { + ctx.redirect("/banking?filter=overview&msg=claimed_pending"); + return; + } + const { txid } = await bankingModel.claimAllocation({ transferId: ctx.params.id, claimerId: userId }); + await bankingModel.publishBankClaim({ amount: allocation.amount, epochId: allocation.concept, allocationId: allocation.id, txid }); ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`); }) .post("/banking/simulate", koaBody(), async (ctx) => { + if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; } const { epochId, rules } = ctx.request.body || {}; ctx.body = await bankingModel.computeEpoch({ epochId, rules }); }) .post("/banking/run", koaBody(), async (ctx) => { + if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; } const { epochId, rules } = ctx.request.body || {}; ctx.body = await bankingModel.executeEpoch({ epochId, rules }); }) .post("/banking/addresses", koaBody(), async (ctx) => { - const b = ctx.request.body || {}, res = await bankingModel.addAddress({ userId: (b.userId || "").trim(), address: (b.address || "").trim() }); + const b = ctx.request.body || {}; + const viewerId = getViewerId(); + const submittedId = (b.userId || "").trim(); + if (submittedId && submittedId !== viewerId) { + ctx.redirect(`/banking?filter=addresses&msg=forbidden`); + return; + } + const res = await bankingModel.addAddress({ userId: viewerId, address: (b.address || "").trim() }); ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`); }) .post("/banking/addresses/delete", koaBody(), async (ctx) => { - const res = await bankingModel.removeAddress({ userId: ((ctx.request.body?.userId) || "").trim() }); + const res = await bankingModel.removeAddress({ userId: getViewerId() }); ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`); }) .post("/favorites/remove/:kind/:id", koaBody(), async (ctx) => { @@ -3889,13 +4752,13 @@ router console.log("oasis@version: updating Oasis...", stdout, stderr); const { stdout: shOut, stderr: shErr } = await exec("sh install.sh"); console.log("oasis@version: running install.sh...", shOut, shErr); - ctx.redirect(new URL(ctx.request.header.referer).href); + safeRefererRedirect(ctx, '/settings'); }) .post("/settings/theme", koaBody(), async (ctx) => { const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig(); cfg.themes.current = theme || "Dark-SNH"; fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); - ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict' }); + ctx.cookies.set("theme", cfg.themes.current, { httpOnly: true, sameSite: 'strict', secure: ctx.secure }); ctx.redirect("/settings"); }) .post("/language", koaBody(), async (ctx) => { @@ -3903,8 +4766,8 @@ router const cfg = getConfig(); cfg.language = lang; fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); - ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict' }); - ctx.redirect(new URL(ctx.request.header.referer).href); + ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict', secure: ctx.secure }); + safeRefererRedirect(ctx, '/settings'); }) .post("/settings/conn/start", koaBody(), async ctx => { await meta.connStart(); ctx.redirect("/peers"); }) .post("/settings/conn/stop", koaBody(), async ctx => { await meta.connStop(); ctx.redirect("/peers"); }) @@ -3981,13 +4844,44 @@ router saveConfig(cfg); ctx.redirect("/settings"); }) - .post("/settings/rebuild", koaBody(), async ctx => { meta.rebuild(); ctx.redirect("/settings"); }) + .post("/inhabitants/follow/accept", koaBody(), async (ctx) => { + const b = ctx.request.body || {}; + const followerId = String(b.followerId || '').trim(); + if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; } + if (viewerFilters.canAutoAcceptNow()) viewerFilters.markAutoAccept(); + viewerFilters.addAccepted(followerId); + viewerFilters.removePending(followerId); + ctx.redirect('/inhabitants?filter=pending'); + }) + .post("/inhabitants/follow/reject", koaBody(), async (ctx) => { + const b = ctx.request.body || {}; + const followerId = String(b.followerId || '').trim(); + if (!followerId) { ctx.redirect('/inhabitants?filter=pending'); return; } + viewerFilters.removeAccepted(followerId); + viewerFilters.removePending(followerId); + ctx.redirect('/inhabitants?filter=pending'); + }) + .post("/settings/wish", koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String(ctx.request.body.wish || '').trim(); + cfg.wish = v === 'mutuals' ? 'mutuals' : 'whole'; + saveConfig(cfg); + ctx.redirect("/settings"); + }) + .post("/settings/pm-visibility", koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String(ctx.request.body.pmVisibility || '').trim(); + cfg.pmVisibility = v === 'mutuals' ? 'mutuals' : 'whole'; + saveConfig(cfg); + ctx.redirect("/settings"); + }) + .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); }) .post("/modules/preset", koaBody(), async (ctx) => { - const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament', 'courts']; + const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; const PRESETS = { - minimal: ['feed', 'forum', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'], - social: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'], - economy: ['agenda', 'audios', 'bookmarks', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs'], + minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'], + social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'], + economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'], full: ALL_MODULES }; const preset = String(ctx.request.body.preset || ''); @@ -3999,7 +4893,7 @@ router ctx.redirect('/modules'); }) .post("/save-modules", koaBody(), async (ctx) => { - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'jobs', 'projects', 'banking', 'parliament', 'courts']; + const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents']; const cfg = getConfig(); modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off'); saveConfig(cfg); @@ -4013,10 +4907,10 @@ router saveConfig(cfg); ctx.redirect("/settings"); }) - .post("/settings/pub-wallet", koaBody(), async (ctx) => { + .post("/settings/pub-id", koaBody(), async (ctx) => { const b = ctx.request.body, cfg = getConfig(); - cfg.walletPub = { url: String(b.wallet_url || "").trim(), user: String(b.wallet_user || "").trim(), pass: String(b.wallet_pass || "").trim() }; - fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); + cfg.walletPub = { pubId: String(b.pub_id || "").trim() }; + saveConfig(cfg); ctx.redirect("/settings"); }) .post('/transfers/create', koaBody(), async ctx => { @@ -4082,22 +4976,11 @@ const middleware = [ const totalCurrent = values.reduce((acc, cur) => acc + cur, 0), totalTarget = status.sync.since * values.length; if (totalTarget - totalCurrent > 1024 * 1024) ctx.response.body = indexingView({ percent: Math.floor((totalCurrent / totalTarget) * 1000) / 10 }); else { try { await next(); } catch (err) { + const { i18n } = require('../views/main_views'); if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) { - const { template, i18n } = require('../views/main_views'); - const referer = ctx.get('referer') || '/'; - ctx.status = 413; - ctx.body = template( - i18n.fileTooLargeTitle, - section( - div({ class: 'tags-header' }, - h2(i18n.fileTooLargeTitle), - p(i18n.fileTooLargeMessage), - p(a({ href: referer, class: 'filter-btn', style: 'display:inline-block;text-decoration:none;margin-top:16px;' }, i18n.goBack)) - ) - ) - ); + sendErrorPage(ctx, i18n.fileTooLargeMessage, { title: i18n.fileTooLargeTitle, status: 413 }); } else { - ctx.status = err.status || 500; ctx.body = { message: err.message || 'Internal Server Error' }; + sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 }); } } } }, @@ -4116,6 +4999,7 @@ const middleware = [ sharedState.setCarbonHcH(hcH); } catch (_) {} try { await refreshInboxCount(); } catch (_) {} + try { await calendarsModel.checkDueReminders(); } catch (_) {} } } await next(); @@ -4123,6 +5007,23 @@ const middleware = [ routes, ]; const app = http({ host, port, middleware, allowHost: config.allowHost }); -app._close = () => { nameWarmup.close(); cooler.close(); }; + +let pubEngineTimer = null; +async function runPubEngineTick() { + if (!bankingModel.isPubNode()) return; + try { await bankingModel.executeEpoch({}); } catch (_) {} + try { await bankingModel.processPendingClaims(); } catch (_) {} + try { await bankingModel.publishPubAvailability(); } catch (_) {} +} +if (bankingModel.isPubNode()) { + setTimeout(() => { runPubEngineTick(); }, 15000); + pubEngineTimer = setInterval(runPubEngineTick, 30 * 60 * 1000); +} + +app._close = () => { + if (pubEngineTimer) clearInterval(pubEngineTimer); + nameWarmup.close(); + cooler.close(); +}; module.exports = app; if (config.open === true) open(url); diff --git a/nodejs-project/nodejs-project/src/backend/blobHandler.js b/nodejs-project/nodejs-project/src/backend/blobHandler.js index e8b6242c..2b8679a8 100644 --- a/nodejs-project/nodejs-project/src/backend/blobHandler.js +++ b/nodejs-project/nodejs-project/src/backend/blobHandler.js @@ -80,7 +80,8 @@ const handleBlobUpload = async function (ctx, fileFieldName) { '.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', - '.svg': 'image/svg+xml', '.bmp': 'image/bmp' + '.svg': 'image/svg+xml', '.bmp': 'image/bmp', + '.torrent': 'application/x-bittorrent' }; const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' }; @@ -101,7 +102,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) { blob.mime = 'application/octet-stream'; } - if (blob.mime.startsWith('image/')) { + if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') { data = await stripImageMetadata(data); } else if (blob.mime === 'application/pdf') { data = stripPdfMetadata(data); @@ -120,6 +121,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) { if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`; if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`; if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`; + if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`; return `\n[${blob.name}](${blob.id})`; }; @@ -213,8 +215,27 @@ const serveBlob = async function (ctx) { if (ft && ft.mime) mime = ft.mime; } catch {} + if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) { + const head = buffer.slice(0, 128).toString('ascii'); + if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent'; + } + + if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') { + let head = buffer.slice(0, 512).toString('utf8'); + if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1); + const trimmed = head.replace(/^\s+/, '').toLowerCase(); + if (trimmed.startsWith(' { + if (!feedId) return null + const entry = cache.get(String(feedId)) + return entry ? entry.name : null +} + +const set = (feedId, name, ts) => { + if (!feedId || typeof name !== 'string' || !name) return + const id = String(feedId) + const t = Number(ts) || 0 + const prev = cache.get(id) + if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t }) +} + +const has = (feedId) => !!feedId && cache.has(String(feedId)) +const size = () => cache.size + +module.exports = { get, set, has, size } diff --git a/nodejs-project/nodejs-project/src/backend/renderTextWithStyles.js b/nodejs-project/nodejs-project/src/backend/renderTextWithStyles.js index c6305f3d..c403a455 100644 --- a/nodejs-project/nodejs-project/src/backend/renderTextWithStyles.js +++ b/nodejs-project/nodejs-project/src/backend/renderTextWithStyles.js @@ -9,13 +9,57 @@ function getI18n() { } } +function renderTextPreview(text, maxLength = 220) { + if (!text) return '' + + let preview = String(text) + + preview = preview + .replace(/```[\s\S]*?```/g, '') + .replace(/^>.*$/gm, '') + .replace(/^#{1,6}\s*/gm, '') + .replace(/^- /gm, '') + .replace(/^\d+\. /gm, '') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/!\[.*?\]\(.*?\)/g, '') + .replace(/\[.*?\]\(.*?\)/g, '') + .replace(/\n+/g, ' ') + .trim() + + if (preview.length > maxLength) { + preview = preview.slice(0, maxLength) + '...' + } + + return preview +} + function renderTextWithStyles(text) { if (!text) return '' const i18n = getI18n() - return String(text) + + let html = String(text) + + html = html .replace(/&/g, '&') .replace(//g, '>') + + html = html + .replace(/```([\s\S]*?)```/gim, '
$1
') + .replace(/^> (.*)$/gim, '
$1
') + .replace(/^---$/gim, '
') + .replace(/^### (.*)$/gim, '

$1

') + .replace(/^## (.*)$/gim, '

$1

') + .replace(/^# (.*)$/gim, '

$1

') + + html = html + .replace(/\*\*(.*?)\*\*/gim, '$1') + .replace(/\*(.*?)\*/gim, '$1') + .replace(/`([^`]+)`/gim, '$1') + + html = html .replace(/!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) => `${alt}` ) @@ -28,21 +72,68 @@ function renderTextWithStyles(text) { .replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) => `${name || i18n.pdfFallbackLabel || 'PDF'}` ) + + html = html .replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) => `@${name}` ) .replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) => `@${id}` ) + + const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''') + html = html .replace(/#(\w+)/g, (_, tag) => - `#${tag}` + `#${escAttr(tag)}` ) - .replace(/(https?:\/\/[^\s]+)/g, url => - `${url}` + .replace(/(https?:\/\/[^\s"'<>]+)/g, url => + `${escAttr(url)}` ) .replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email => - `${email}` + `${escAttr(email)}` ) + + const lines = html.split('\n') + let result = '' + let inUL = false + let inOL = false + + for (let line of lines) { + if (/^- /.test(line)) { + if (!inUL) { + result += '
    ' + inUL = true + } + result += `
  • ${line.replace(/^- /, '')}
  • ` + continue + } + + if (/^\d+\. /.test(line)) { + if (!inOL) { + result += '
      ' + inOL = true + } + result += `
    1. ${line.replace(/^\d+\. /, '')}
    2. ` + continue + } + + if (inUL) { + result += '
' + inUL = false + } + + if (inOL) { + result += '' + inOL = false + } + + result += line + '
' + } + + if (inUL) result += '' + if (inOL) result += '' + + return result } -module.exports = { renderTextWithStyles } +module.exports = { renderTextWithStyles, renderTextPreview } diff --git a/nodejs-project/nodejs-project/src/client/assets/styles/mobile.css b/nodejs-project/nodejs-project/src/client/assets/styles/mobile.css index e9cfecda..b5cbc1e7 100644 --- a/nodejs-project/nodejs-project/src/client/assets/styles/mobile.css +++ b/nodejs-project/nodejs-project/src/client/assets/styles/mobile.css @@ -1022,30 +1022,37 @@ pre, code { } .actpager { - margin-top: 0 !important; + margin: 0 !important; } .actpager-frame { - gap: 8px !important; - padding: 6px 0 !important; + gap: 4px !important; + padding: 0 !important; } .actpager-cell { - gap: 10px !important; + gap: 6px !important; +} + +.actpager-cell .filter-btn { + padding: 6px 8px !important; + min-height: 32px !important; + font-size: 0.78rem !important; } .actpager-controls { - margin-top: 4px !important; + margin: 0 !important; } .actpager-arrows { - gap: 40px !important; + gap: 32px !important; + padding: 0 !important; } .actpager-arrow { - width: 32px !important; - height: 32px !important; - font-size: 1.3rem !important; + width: 28px !important; + height: 28px !important; + font-size: 1.15rem !important; background: transparent !important; border: 1px solid #555 !important; } diff --git a/nodejs-project/nodejs-project/src/client/assets/styles/style.css b/nodejs-project/nodejs-project/src/client/assets/styles/style.css index c08fa11c..40dac799 100644 --- a/nodejs-project/nodejs-project/src/client/assets/styles/style.css +++ b/nodejs-project/nodejs-project/src/client/assets/styles/style.css @@ -240,6 +240,7 @@ nav ul li a:hover { display: flex; align-items: center; justify-content: flex-start; + gap: 0.5rem; cursor: pointer; padding: 0.5rem 0.75rem; font-weight: 600; @@ -255,6 +256,20 @@ nav ul li a:hover { box-sizing: border-box; } +.oasis-nav-header .emoji { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4em; + flex-shrink: 0; + text-align: center; + line-height: 1; +} + +.oasis-nav-header .nav-text { + flex: 1; +} + .oasis-nav-header:hover { background: rgba(255, 255, 255, 0.05); color: #ffd36a; @@ -295,6 +310,7 @@ nav ul li a:hover { .oasis-nav-list li a { display: flex; align-items: center; + gap: 0.5rem; padding: 0.35rem 1.25rem 0.35rem 1.5rem; font-size: 0.85rem; text-decoration: none; @@ -307,7 +323,18 @@ nav ul li a:hover { } .oasis-nav-list .emoji { - margin-right: 0.4rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4em; + flex-shrink: 0; + text-align: center; + line-height: 1; +} + +.oasis-nav-list .nav-text { + display: inline-block; + line-height: 1.2; } .oasis-header-marquee { @@ -459,6 +486,7 @@ nav ul li a:hover { .top-bar-left, .top-bar-mid, +.top-bar-center, .top-bar-right { display: flex; align-items: center; @@ -474,10 +502,78 @@ nav ul li a:hover { justify-content: center; } +.top-bar-center { + flex: 1; + justify-content: center; + min-width: 0; +} + .top-bar-right { justify-content: flex-end; } +.ai-ask-form { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + max-width: 720px; +} + +.ai-ask-form .ai-ask-input, +.ai-ask-form .ai-ask-btn { + box-sizing: border-box; + margin: 0; + height: 32px; + border: 1px solid rgba(255, 180, 0, 0.6); + border-radius: 4px; + padding: 0 0.75rem; + font-size: 0.8rem; + font-family: inherit; + font-weight: 600; + line-height: 1; + background: transparent; + color: #ffb400; + text-transform: none; + letter-spacing: normal; +} + +.ai-ask-form .ai-ask-input { + flex: 1; + min-width: 0; + outline: none; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.ai-ask-form .ai-ask-input::placeholder { + color: rgba(255, 180, 0, 0.55); +} + +.ai-ask-form .ai-ask-input:focus { + border-color: #ffa300; + background: rgba(255, 255, 255, 0.05); +} + +.ai-ask-form .ai-ask-btn { + flex-shrink: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + padding: 0 0.85rem; +} + +.ai-ask-form .ai-ask-btn:hover { + background: rgba(255, 180, 0, 0.15); + border-color: #ffa300; + color: #ffa300; +} + +@media (max-width: 768px) { + .top-bar-center { display: none; } +} + .top-bar-left nav ul, .top-bar-mid nav ul, .top-bar-right nav ul { @@ -1038,6 +1134,9 @@ button.create-button:hover { color: #ffa300; } +.error-page-message{margin:8px 0 16px;white-space:pre-wrap;word-break:break-word} +.error-page-actions{margin-top:16px} + .tags-container { display: flex; flex-wrap: wrap; @@ -3122,7 +3221,9 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; .pledge-box input[type="number"]:focus{ border-color:#3a475c; } -.card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px} +.card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px;flex-wrap:wrap} +.card-field>.card-label{white-space:nowrap;flex-shrink:0} +.card-field>.card-value{min-width:0;word-break:break-word;overflow-wrap:anywhere;flex:1 1 auto} .card-field-stacked{flex-direction:column;align-items:flex-start;gap:2px} .card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word} .card-field-rich{flex-direction:column;align-items:flex-start} @@ -3276,8 +3377,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; .carbon-bar-network{background:#2ecc71 !important} .carbon-bar-mine{background:#3498db !important} .carbon-bar-max{background:#555 !important;border:none !important} -.carbon-bar-note{font-size:13px;color:#888;margin:6px 0 2px} -.carbon-bar-formula{font-size:12px;color:#999;margin:2px 0} +.carbon-bar-note{font-size:13px;color:#ffd700;margin:6px 0 2px} +.carbon-bar-formula{font-size:12px;color:#ffd700;margin:2px 0 10px} /* parliament */ .cycle-info { @@ -3726,6 +3827,26 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; align-self: flex-end; } +.tribe-parent-box { + margin-bottom: 12px; + text-align: center; +} + +.tribe-parent-box h2 { + margin: 0 0 8px 0; +} + +.tribe-parent-link { + display: block; +} + +.tribe-parent-image { + width: 100%; + max-width: 200px; + border-radius: 8px; + border: 2px solid #444; +} + .tribe-card-meta-row { display: flex; gap: .5em; @@ -4123,50 +4244,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; align-self: flex-end; } -.no-border{border:none} -.tribe-card-padded{padding:12px 16px} -.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px} -.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center} -.bold{font-weight:bold} -.inline-form{display:inline} - -.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db} -.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6} -.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6} -.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c} -.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c} -.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085} -.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71} -.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71} -.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22} -.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22} -.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c} -.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c} -.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12} -.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12} -.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b} -.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b} -.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db} -.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db} -.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad} -.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400} -.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60} -.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f} -.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6} -.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6} -.stats-link{color:#007bff;text-decoration:none} -.stats-link-break{color:#007bff;text-decoration:none;word-break:break-all} -.stats-muted-555{color:#555} -.stats-muted-888{color:#888} -.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px} -.stats-section-h{font-size:18px;color:#555;margin:8px 0;font-weight:600} -.stats-list-reset{list-style-type:none;padding:0;margin:0} -.stats-mb-16{margin-bottom:16px} -.stats-w-100{width:100%} -.stats-table{width:100%;border-collapse:collapse} -.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px} -.stats-h-row{font-size:18px;color:#555;margin:8px 0} - .comment-submit-btn { width: auto; max-width: 200px; @@ -5033,89 +5110,107 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0; .logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all} .logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0} .logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px} +.no-border{border:none} +.tribe-card-padded{padding:12px 16px} +.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px} +.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center} +.bold{font-weight:bold} +.inline-form{display:inline} +.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db} +.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6} +.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6} +.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c} +.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c} +.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085} +.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71} +.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71} +.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22} +.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22} +.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c} +.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c} +.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12} +.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12} +.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b} +.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b} +.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db} +.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db} +.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad} +.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400} +.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60} +.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f} +.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6} +.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6} +.stats-link{color:#ffa500;text-decoration:none} +.stats-link-break{color:#ffa500;text-decoration:none;word-break:break-all} +.stats-muted{color:#aaa} +.stats-strong{color:#ffd700;font-weight:600} +.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px} +.stats-mode-row{justify-content:flex-start;margin-bottom:24px} +.stats-section-h{font-size:18px;color:#ffa500;margin:8px 0;font-weight:600} +.stats-list-reset{list-style-type:none;padding:0;margin:0} +.stats-mb-16{margin-bottom:16px} +.stats-w-100{width:100%} +.stats-table{width:100%;border-collapse:collapse} +.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px} +.stats-h-row{font-size:15px;color:#ddd;margin:6px 0;display:flex;gap:8px;align-items:baseline} +.stats-card{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px;box-shadow:none} +.stats-card .block-info-table{table-layout:fixed} +.stats-card .block-info-table .card-label{width:70%} +.stats-card .block-info-table .card-value{width:30%;text-align:right;word-break:break-word} +.invites-pubs-table{table-layout:fixed} +.invites-pubs-table col.invites-col-host,.invites-pubs-table td:nth-child(1){width:22%} +.invites-pubs-table td:nth-child(2){width:8%} +.invites-pubs-table td:nth-child(3){width:40%;word-break:break-all} +.invites-pubs-table td:nth-child(4){width:30%} +.invites-pubs-table tr:first-child td{white-space:nowrap} +.stats-block{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px} +.stats-block h2{margin:0 0 10px;font-size:16px;color:#ffa500;font-weight:600} +.stats-block ul{margin:6px 0 0;padding-left:18px} +.stats-block ul li{margin:3px 0;color:#ddd;font-size:14px} +.stats-block table.stats-table th,.stats-block table.stats-table-mt8 th{background:#272727;color:#ffa500;text-align:left;padding:6px 8px;font-size:13px} +.stats-block table.stats-table td,.stats-block table.stats-table-mt8 td{padding:5px 8px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:13px} +.stats-block table.stats-table tr:last-child td,.stats-block table.stats-table-mt8 tr:last-child td{border-bottom:none} +.stats-pill{display:inline-block;padding:2px 8px;border-radius:10px;background:#2a2a2a;border:1px solid #3a3a3a;color:#ffd700;font-size:12px;margin:2px 4px 2px 0} +.stats-toplist{margin:0;padding:0;list-style:none} +.stats-toplist li{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid #2a2a2a} +.stats-toplist li:last-child{border-bottom:none} +.stats-toplist .stats-bar-track{flex:1;background:#2a2a2a;border-radius:4px;height:8px;overflow:hidden} +.stats-toplist .stats-bar-fill{background:#ffa500;height:100%} +.stats-toplist .stats-toplist-name{flex:0 0 auto;color:#ddd;font-size:13px;min-width:120px} +.stats-toplist .stats-toplist-num{flex:0 0 auto;color:#ffd700;font-size:13px;min-width:36px;text-align:right} +.peer-key{word-break:break-all;font-size:12px} +.graphos-canvas{width:100%;max-width:1100px;margin:12px auto;padding:8px;background:transparent} +.graphos-svg{width:100%;height:auto;min-height:480px;display:block} +.graphos-edge{stroke:#666;stroke-width:1;opacity:.55} +.graphos-edge-online{stroke:#2ecc71;stroke-width:2;opacity:.9} +.graphos-edge-discovered{stroke:#ffd700;stroke-width:1.5;opacity:.75} +.graphos-edge-unknown{stroke:#888;stroke-width:1;stroke-dasharray:4 4;opacity:.55} +.graphos-node-circle{stroke:#1a1a1a;stroke-width:2;transition:stroke-width .15s,filter .15s} +.graphos-node-circle-me{fill:#ffa500;stroke:#cc7700;stroke-width:3} +.graphos-node-circle-online{fill:#2ecc71;stroke:#1e8449} +.graphos-node-circle-discovered{fill:#ffd700;stroke:#b8860b} +.graphos-node-circle-unknown{fill:#888;stroke:#555} +.graphos-node-link{cursor:pointer;outline:none} +.graphos-node-link:hover .graphos-node-circle{stroke-width:4;filter:brightness(1.15)} +.graphos-node-link:hover .graphos-node-label{font-weight:600} +.graphos-node-label{font-size:12px;fill:#ddd;text-anchor:middle;pointer-events:none} +.graphos-node-label-me{font-weight:600;fill:#ffa500} +.graphos-legend{display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:13px;color:#ddd;margin:0 0 8px} +.graphos-legend-item{display:flex;align-items:center;gap:6px} +.graphos-legend-dot{display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid #1a1a1a} +.graphos-legend-dot.graphos-node-circle-me{background:#ffa500;border-color:#cc7700} +.graphos-legend-dot.graphos-node-circle-online{background:#2ecc71;border-color:#1e8449} +.graphos-legend-dot.graphos-node-circle-discovered{background:#ffd700;border-color:#b8860b} +.graphos-legend-dot.graphos-node-circle-unknown{background:#888;border-color:#555} +.stats-kpi{padding:8px 4px;display:flex;flex-direction:column;gap:4px;background:transparent;border:none} +.stats-kpi-label{color:#ffa500;font-size:12px;font-weight:600;letter-spacing:.3px} +.stats-kpi-value{color:#ffd700;font-weight:600;font-size:22px;line-height:1.1;word-break:break-word} +.stats-kpi-bar{margin-top:6px} +.stats-activity-totals{display:flex;gap:24px;flex-wrap:wrap;color:#ddd;font-size:13px;margin-top:8px} +.stats-activity-totals strong{color:#ffd700} +.stats-w-0{width:0%}.stats-w-5{width:5%}.stats-w-10{width:10%}.stats-w-15{width:15%}.stats-w-20{width:20%}.stats-w-25{width:25%}.stats-w-30{width:30%}.stats-w-35{width:35%}.stats-w-40{width:40%}.stats-w-45{width:45%}.stats-w-50{width:50%}.stats-w-55{width:55%}.stats-w-60{width:60%}.stats-w-65{width:65%}.stats-w-70{width:70%}.stats-w-75{width:75%}.stats-w-80{width:80%}.stats-w-85{width:85%}.stats-w-90{width:90%}.stats-w-95{width:95%}.stats-w-100{width:100%} -.sidebar-panel { - display: contents; -} -.sidebar-left { order: 1; } -.main-column { order: 2; } -.sidebar-right { order: 3; } -.panel-quicklinks { display: none; } - -.qr-code svg { - max-width: 200px; - width: 100%; - height: auto; - display: block; - margin: 8px auto; -} -.qr-code-inline svg { - max-width: 140px; - margin: 4px 0; -} -.qr-code-profile svg { - max-width: 160px; - margin: 8px auto; -} -.user-id-qr { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - width: 100%; - margin-top: 8px; -} -.user-id-label { - font-size: 10px; - font-family: monospace; - word-break: break-all; - opacity: 0.6; - text-align: center; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.invite-code-text { - font-family: 'Courier New', Courier, monospace; - font-size: 13px; - word-break: break-all; - padding: 8px 12px; - border-radius: 6px; - letter-spacing: 0.5px; - user-select: all; -} -.invite-page { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - padding: 16px; - max-width: 480px; - margin: 0 auto; -} -.invite-log-block { - margin-top: 8px; - padding: 6px 8px; - border-left: 3px solid #27ae60; - background: rgba(39,174,96,0.06); - border-radius: 0 4px 4px 0; -} -.invite-status--pending { color: #e67e22; font-weight: bold; } -.invite-status--used { color: #27ae60; font-weight: bold; } -.invite-code-cell { - font-family: monospace; - font-size: 11px; -} -.invite-key { - font-size: 11px; - word-break: break-all; -} -.trending-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 20px; -} +/* === Mobile additions (QR share + mobile menu hide on desktop) === */ .qr-share-details { margin: 8px 0; width: 100%; diff --git a/nodejs-project/nodejs-project/src/client/assets/themes/Clear-SNH.css b/nodejs-project/nodejs-project/src/client/assets/themes/Clear-SNH.css index b3bffbee..95c202ad 100644 --- a/nodejs-project/nodejs-project/src/client/assets/themes/Clear-SNH.css +++ b/nodejs-project/nodejs-project/src/client/assets/themes/Clear-SNH.css @@ -394,6 +394,17 @@ a.user-link:focus { background: #ccc !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; } +.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; } diff --git a/nodejs-project/nodejs-project/src/client/assets/themes/Dark-SNH.css b/nodejs-project/nodejs-project/src/client/assets/themes/Dark-SNH.css index 42df4198..c282e90a 100644 --- a/nodejs-project/nodejs-project/src/client/assets/themes/Dark-SNH.css +++ b/nodejs-project/nodejs-project/src/client/assets/themes/Dark-SNH.css @@ -312,6 +312,12 @@ a.user-link:focus { } /* 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; } diff --git a/nodejs-project/nodejs-project/src/client/assets/themes/Matrix-SNH.css b/nodejs-project/nodejs-project/src/client/assets/themes/Matrix-SNH.css index 55f6eac8..ebd77f02 100644 --- a/nodejs-project/nodejs-project/src/client/assets/themes/Matrix-SNH.css +++ b/nodejs-project/nodejs-project/src/client/assets/themes/Matrix-SNH.css @@ -405,6 +405,13 @@ a.user-link:focus { 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; } diff --git a/nodejs-project/nodejs-project/src/client/assets/themes/Purple-SNH.css b/nodejs-project/nodejs-project/src/client/assets/themes/Purple-SNH.css index d361702a..351fac1d 100644 --- a/nodejs-project/nodejs-project/src/client/assets/themes/Purple-SNH.css +++ b/nodejs-project/nodejs-project/src/client/assets/themes/Purple-SNH.css @@ -440,6 +440,12 @@ a.user-link:focus { 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; } diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ar.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ar.js index 482c30eb..07e9d372 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ar.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ar.js @@ -38,6 +38,11 @@ module.exports = { ], profile: "الصورة الرمزية", inhabitants: "السكان", + peersReplicatedFeeds: "الموجزات المنسوخة", + graphos: "غرافوس", + graphosDescription: "خريطة تفاعلية للشبكة من حولك.", + graphosYou: "أنت", + graphosTotalNodes: "إجمالي العقد", manualMode: "الوضع اليدوي", mentions: "الإشارات", mentionsDescription: [ @@ -2148,6 +2153,7 @@ module.exports = { bankAddressInvalid: 'عنوان غير صالح', bankAddressDeleted: 'تم حذف العنوان', bankAddressNotFound: 'لم يتم العثور على العنوان', + bankAddressForbidden: 'يمكنك فقط تعيين عنوان الدفع الخاص بك', bankAddressTotal: 'إجمالي العناوين', bankAddressSearch: 'ابحث عن @ساكن أو عنوان', bankAddressActions: 'الإجراءات', @@ -2753,6 +2759,11 @@ module.exports = { fileTooLargeTitle: "الملف كبير جدًا", fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.", goBack: "رجوع", + errorPageTitle: "حدث خطأ", + aiNavPlaceholder: "إلى أين تريد الذهاب؟", + aiNavDisabled: "تعطيل التنقل بالذكاء الاصطناعي.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "وحدة لإجراء استعلامات بلغة طبيعية حول محتوى الشبكة.", directConnect: "اتصال مباشر", directConnectDescription: "اتصل مباشرة بقرين عن طريق إدخال عنوان IP والمنفذ والمفتاح العام. سيتم إضافة القرين كاتصال مُتابَع.", peerHost: "IP / اسم المضيف", @@ -2948,6 +2959,8 @@ module.exports = { gamesBackToGames: "العودة إلى الألعاب", modulesGamesLabel: "الألعاب", modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.", + modulesGraphosLabel: "غرافوس", + modulesGraphosDescription: "وحدة لاستكشاف الشبكة كخريطة تفاعلية للعقد.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.", gamesTheFlowTitle: "ECOinflow", @@ -3017,6 +3030,9 @@ module.exports = { calendarCreated: "تم الإنشاء", calendarAuthor: "المؤلف", calendarJoin: "انضمام", + calendarGenerateInvite: "إنشاء دعوة", + calendarInviteCodePlaceholder: "أدخل رمز الدعوة...", + calendarValidateInvite: "التحقق من الرمز", calendarJoined: "منضم", calendarAddDate: "إضافة تاريخ", calendarAddNote: "إضافة ملاحظة", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_de.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_de.js index 76ef4f26..ccd7c217 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_de.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_de.js @@ -37,7 +37,12 @@ module.exports = { " von Bewohnern, die du unterstützt (inkl. Multiversum), nach Aktualität sortiert.", ], profile: "Avatar", - inhabitants: "Bewohner", + inhabitants: "Bewohner", + peersReplicatedFeeds: "Replizierte Feeds", + graphos: "Graphos", + graphosDescription: "Interaktive Karte des Netzwerks um dich herum.", + graphosYou: "Du", + graphosTotalNodes: "Knoten insgesamt", manualMode: "Manueller Modus", mentions: "Erwähnungen", mentionsDescription: [ @@ -2147,6 +2152,7 @@ module.exports = { bankAddressInvalid: 'Ungültige Adresse', bankAddressDeleted: 'Adresse gelöscht', bankAddressNotFound: 'Adresse nicht gefunden', + bankAddressForbidden: 'Du kannst nur deine eigene Auszahlungsadresse festlegen', bankAddressTotal: 'Adressen gesamt', bankAddressSearch: 'Nach @Bewohner oder Adresse suchen', bankAddressActions: 'Aktionen', @@ -2752,6 +2758,11 @@ module.exports = { fileTooLargeTitle: "Datei zu groß", fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (50 MB). Bitte wähle eine kleinere Datei.", goBack: "Zurück", + errorPageTitle: "Etwas ist schiefgelaufen", + aiNavPlaceholder: "Wohin möchtest du gehen?", + aiNavDisabled: "KI-Navigation ist deaktiviert.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Modul für natürlichsprachliche Anfragen zum Inhalt des Netzwerks.", directConnect: "Direkte Verbindung", directConnectDescription: "Verbinde dich direkt mit einem Peer, indem du IP-Adresse, Port und öffentlichen Schlüssel eingibst.", peerHost: "IP / Hostname", @@ -2891,6 +2902,8 @@ module.exports = { gamesBackToGames: "Zurück zu Spielen", modulesGamesLabel: "Spiele", modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Modul, um das Netzwerk als interaktive Karte der Knoten zu erkunden.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.", gamesTheFlowTitle: "ECOinflow", @@ -3013,6 +3026,9 @@ module.exports = { calendarCreated: "Erstellt", calendarAuthor: "Autor", calendarJoin: "Beitreten", + calendarGenerateInvite: "Einladung erstellen", + calendarInviteCodePlaceholder: "Einladungscode eingeben...", + calendarValidateInvite: "Code prüfen", calendarJoined: "Beigetreten", calendarAddDate: "Datum hinzufügen", calendarAddNote: "Notiz hinzufügen", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_en.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_en.js index b527f300..465ec272 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_en.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_en.js @@ -37,7 +37,12 @@ module.exports = { " from inhabitants you support (included from multiverse), sorted by recency.", ], profile: "Avatar", - inhabitants: "Inhabitants", + inhabitants: "Inhabitants", + peersReplicatedFeeds: "Replicated feeds", + graphos: "Graphos", + graphosDescription: "Interactive map of the network around you.", + graphosYou: "You", + graphosTotalNodes: "Total nodes", manualMode: "Manual Mode", mentions: "Mentions", mentionsDescription: [ @@ -2153,6 +2158,7 @@ module.exports = { bankAddressInvalid: 'Invalid address', bankAddressDeleted: 'Address deleted', bankAddressNotFound: 'Address not found', + bankAddressForbidden: 'You can only set your own payout address', bankAddressTotal: 'Total Addresses', bankAddressSearch: 'Search @inhabitant or address', bankAddressActions: 'Actions', @@ -2758,6 +2764,11 @@ module.exports = { fileTooLargeTitle: "File too large", fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.", goBack: "Go back", + errorPageTitle: "Something went wrong", + aiNavPlaceholder: "Where do you want to go?", + aiNavDisabled: "AI navigation is disabled.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Module for natural-language queries about the network's content.", directConnect: "Direct Connect", directConnectDescription: "Connect directly to a peer by entering their IP address, port and public key. The peer will be added as a followed connection.", peerHost: "IP / Hostname", @@ -2921,6 +2932,9 @@ module.exports = { calendarCreated: "Created", calendarAuthor: "Author", calendarJoin: "Join Calendar", + calendarGenerateInvite: "Generate invite", + calendarInviteCodePlaceholder: "Enter invite code...", + calendarValidateInvite: "Validate code", calendarJoined: "Joined", calendarAddDate: "Add Date", calendarAddNote: "Add Note", @@ -3024,6 +3038,8 @@ module.exports = { gamesBackToGames: "Back to Games", modulesGamesLabel: "Games", modulesGamesDescription: "Module to discover and play some games.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Module to explore the network as an interactive map of peers.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?", gamesTheFlowTitle: "ECOinflow", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_es.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_es.js index 41d38ecf..5e76195e 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_es.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_es.js @@ -38,6 +38,11 @@ module.exports = { ], profile: "Avatar", inhabitants: "Habitantes", + peersReplicatedFeeds: "Feeds replicados", + graphos: "Graphos", + graphosDescription: "Mapa interactivo de la red alrededor de ti.", + graphosYou: "Tú", + graphosTotalNodes: "Nodos totales", manualMode: "Modo Manual", mentions: "Menciones", mentionsDescription: [ @@ -2151,6 +2156,7 @@ module.exports = { bankAddressInvalid: 'Dirección no válida', bankAddressDeleted: 'Dirección eliminada', bankAddressNotFound: 'Dirección no encontrada', + bankAddressForbidden: 'Solo puedes configurar tu propia dirección de cobro', bankAddressTotal: 'Total de Direcciones', bankAddressSearch: 'Buscar @habitante o dirección', bankAddressActions: 'Acciones', @@ -2757,6 +2763,11 @@ module.exports = { fileTooLargeTitle: "Archivo demasiado grande", fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (50 MB). Por favor, selecciona un archivo más pequeño.", goBack: "Volver", + errorPageTitle: "Ha ocurrido un error", + aiNavPlaceholder: "¿A dónde quieres ir?", + aiNavDisabled: "La navegación con IA está desactivada.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Módulo para realizar peticiones en lenguaje natural sobre el contenido de la red.", directConnect: "Conexión Directa", directConnectDescription: "Conéctate directamente a un nodo introduciendo su dirección IP, puerto y clave pública. El nodo se añadirá como conexión seguida.", peerHost: "IP / Nombre de host", @@ -2922,6 +2933,9 @@ module.exports = { calendarCreated: "Creado", calendarAuthor: "Autor", calendarJoin: "Unirse al Calendario", + calendarGenerateInvite: "Generar invitación", + calendarInviteCodePlaceholder: "Introduce código...", + calendarValidateInvite: "Validar código", calendarJoined: "Unido", calendarAddDate: "Añadir Fecha", calendarAddNote: "Añadir Nota", @@ -3025,6 +3039,8 @@ module.exports = { gamesBackToGames: "Volver a Juegos", modulesGamesLabel: "Juegos", modulesGamesDescription: "Módulo para descubrir y jugar algunos juegos.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Módulo para explorar la red como un mapa interactivo de pares.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?", gamesTheFlowTitle: "ECOinflow", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_eu.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_eu.js index 382f239d..f63f0926 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_eu.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_eu.js @@ -37,7 +37,12 @@ module.exports = { " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.", ], profile: "Abatarra", - inhabitants: "Bizilagunak", + inhabitants: "Bizilagunak", + peersReplicatedFeeds: "Errepikatutako jarioak", + graphos: "Graphos", + graphosDescription: "Sarearen mapa interaktiboa zure inguruan.", + graphosYou: "Zu", + graphosTotalNodes: "Nodo guztiak", manualMode: "Eskuzko modua", mentions: "Aipamenak", mentionsDescription: [ @@ -2118,6 +2123,7 @@ module.exports = { bankAddressInvalid: 'Helbide baliogabea', bankAddressDeleted: 'Helbidea ezabatuta', bankAddressNotFound: 'Helbiderik ez da aurkitu', + bankAddressForbidden: 'Zure ordainketa-helbidea soilik konfigura dezakezu', bankAddressTotal: 'Guztira', bankAddressSearch: 'Erabiltzailea edo helbidea bilatu', bankAddressActions: 'Ekintzak', @@ -2724,6 +2730,11 @@ module.exports = { fileTooLargeTitle: "Fitxategia handiegia", fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.", goBack: "Itzuli", + errorPageTitle: "Zerbait gaizki joan da", + aiNavPlaceholder: "Nora joan nahi duzu?", + aiNavDisabled: "AI nabigazioa desgaituta dago.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Sareko edukiari buruzko hizkuntza naturalezko galderak egiteko modulua.", directConnect: "Zuzeneko Konexioa", directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.", peerHost: "IP / Ostalari-izena", @@ -2918,6 +2929,8 @@ module.exports = { gamesBackToGames: "Jokoetara itzuli", modulesGamesLabel: "Jokoak", modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Sarea nodoen mapa interaktibo gisa esploratzeko modulua.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.", gamesTheFlowTitle: "ECOinflow", @@ -2987,6 +3000,9 @@ module.exports = { calendarCreated: "Sortua", calendarAuthor: "Egilea", calendarJoin: "Batu", + calendarGenerateInvite: "Sortu gonbidapena", + calendarInviteCodePlaceholder: "Sartu gonbidapen-kodea...", + calendarValidateInvite: "Egiaztatu kodea", calendarJoined: "Batuta", calendarAddDate: "Data gehitu", calendarAddNote: "Oharra gehitu", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_fr.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_fr.js index cd32ffdf..b6e0de27 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_fr.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_fr.js @@ -38,6 +38,11 @@ module.exports = { ], profile: "Avatar", inhabitants: "Habitants", + peersReplicatedFeeds: "Flux répliqués", + graphos: "Graphos", + graphosDescription: "Carte interactive du réseau autour de toi.", + graphosYou: "Toi", + graphosTotalNodes: "Nœuds totaux", manualMode: "Mode Manuel", mentions: "Mentions", mentionsDescription: [ @@ -2143,6 +2148,7 @@ module.exports = { bankAddressInvalid: 'Adresse non valide', bankAddressDeleted: 'Adresse supprimée', bankAddressNotFound: 'Adresse introuvable', + bankAddressForbidden: 'Vous ne pouvez configurer que votre propre adresse de paiement', bankAddressTotal: 'Total des adresses', bankAddressSearch: 'Rechercher @habitant ou adresse', bankAddressActions: 'Actions', @@ -2749,6 +2755,11 @@ module.exports = { fileTooLargeTitle: "Fichier trop volumineux", fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.", goBack: "Retour", + errorPageTitle: "Une erreur est survenue", + aiNavPlaceholder: "Où veux-tu aller ?", + aiNavDisabled: "La navigation par IA est désactivée.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Module pour des requêtes en langage naturel sur le contenu du réseau.", directConnect: "Connexion Directe", directConnectDescription: "Connectez-vous directement à un pair en saisissant son adresse IP, port et clé publique. Le pair sera ajouté comme connexion suivie.", peerHost: "IP / Nom d'hôte", @@ -2946,6 +2957,8 @@ module.exports = { gamesBackToGames: "Retour aux Jeux", modulesGamesLabel: "Jeux", modulesGamesDescription: "Module pour découvrir et jouer à des jeux.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Module pour explorer le réseau comme une carte interactive des nœuds.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.", gamesTheFlowTitle: "ECOinflow", @@ -3015,6 +3028,9 @@ module.exports = { calendarCreated: "Créé", calendarAuthor: "Auteur", calendarJoin: "Rejoindre", + calendarGenerateInvite: "Générer une invitation", + calendarInviteCodePlaceholder: "Entrez le code d'invitation...", + calendarValidateInvite: "Valider le code", calendarJoined: "Rejoint", calendarAddDate: "Ajouter une date", calendarAddNote: "Ajouter une note", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_hi.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_hi.js index 2e0fe44b..e3cdf5c9 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_hi.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_hi.js @@ -38,6 +38,11 @@ module.exports = { ], profile: "अवतार", inhabitants: "निवासी", + peersReplicatedFeeds: "प्रतिकृत फ़ीड", + graphos: "ग्राफोस", + graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।", + graphosYou: "आप", + graphosTotalNodes: "कुल नोड्स", manualMode: "मैनुअल मोड", mentions: "उल्लेख", mentionsDescription: [ @@ -2148,6 +2153,7 @@ module.exports = { bankAddressInvalid: 'अमान्य पता', bankAddressDeleted: 'पता हटाया गया', bankAddressNotFound: 'पता नहीं मिला', + bankAddressForbidden: 'आप केवल अपना भुगतान पता सेट कर सकते हैं', bankAddressTotal: 'कुल पते', bankAddressSearch: '@निवासी या पता खोजें', bankAddressActions: 'कार्रवाई', @@ -2753,6 +2759,11 @@ module.exports = { fileTooLargeTitle: "फ़ाइल बहुत बड़ी", fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (50 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।", goBack: "वापस जाएँ", + errorPageTitle: "कुछ गलत हो गया", + aiNavPlaceholder: "आप कहाँ जाना चाहते हैं?", + aiNavDisabled: "AI नेविगेशन अक्षम है।", + modulesAINavLabel: "AINav", + modulesAINavDescription: "नेटवर्क की सामग्री पर प्राकृतिक भाषा में प्रश्न करने का मॉड्यूल।", directConnect: "सीधा कनेक्शन", directConnectDescription: "किसी पीयर से सीधे कनेक्ट करने के लिए उनका IP पता, पोर्ट और सार्वजनिक कुंजी दर्ज करें। पीयर को एक अनुसरित कनेक्शन के रूप में जोड़ा जाएगा।", peerHost: "IP / होस्टनाम", @@ -2948,6 +2959,8 @@ module.exports = { gamesBackToGames: "खेलों पर वापस जाएं", modulesGamesLabel: "खेल", modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।", + modulesGraphosLabel: "ग्राफोस", + modulesGraphosDescription: "नोड्स के इंटरैक्टिव मानचित्र के रूप में नेटवर्क का अन्वेषण करने का मॉड्यूल।", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।", gamesTheFlowTitle: "ECOinflow", @@ -3017,6 +3030,9 @@ module.exports = { calendarCreated: "बनाया गया", calendarAuthor: "लेखक", calendarJoin: "जुड़ें", + calendarGenerateInvite: "आमंत्रण बनाएं", + calendarInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...", + calendarValidateInvite: "कोड सत्यापित करें", calendarJoined: "जुड़े हुए", calendarAddDate: "तारीख जोड़ें", calendarAddNote: "नोट जोड़ें", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_it.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_it.js index 6cbe888e..fe2143eb 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_it.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_it.js @@ -37,7 +37,12 @@ module.exports = { " dagli abitanti che supporti (incluso dal multiverso), ordinati per data.", ], profile: "Avatar", - inhabitants: "Abitanti", + inhabitants: "Abitanti", + peersReplicatedFeeds: "Feed replicati", + graphos: "Graphos", + graphosDescription: "Mappa interattiva della rete attorno a te.", + graphosYou: "Tu", + graphosTotalNodes: "Nodi totali", manualMode: "Modalità manuale", mentions: "Menzioni", mentionsDescription: [ @@ -2148,6 +2153,7 @@ module.exports = { bankAddressInvalid: 'Indirizzo non valido', bankAddressDeleted: 'Indirizzo eliminato', bankAddressNotFound: 'Indirizzo non trovato', + bankAddressForbidden: 'Puoi impostare solo il tuo indirizzo di pagamento', bankAddressTotal: 'Indirizzi totali', bankAddressSearch: 'Cerca @abitante o indirizzo', bankAddressActions: 'Azioni', @@ -2753,6 +2759,11 @@ module.exports = { fileTooLargeTitle: "File troppo grande", fileTooLargeMessage: "Il file supera la dimensione massima consentita (50 MB). Seleziona un file più piccolo.", goBack: "Torna indietro", + errorPageTitle: "Si è verificato un errore", + aiNavPlaceholder: "Dove vuoi andare?", + aiNavDisabled: "La navigazione con IA è disattivata.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Modulo per query in linguaggio naturale sui contenuti della rete.", directConnect: "Connessione diretta", directConnectDescription: "Connettiti direttamente a un peer inserendo indirizzo IP, porta e chiave pubblica.", peerHost: "IP / Nome host", @@ -2949,6 +2960,8 @@ module.exports = { gamesBackToGames: "Torna ai Giochi", modulesGamesLabel: "Giochi", modulesGamesDescription: "Modulo per scoprire e giocare ad alcuni giochi.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Modulo per esplorare la rete come una mappa interattiva dei nodi.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.", gamesTheFlowTitle: "ECOinflow", @@ -3018,6 +3031,9 @@ module.exports = { calendarCreated: "Creato", calendarAuthor: "Autore", calendarJoin: "Partecipa", + calendarGenerateInvite: "Genera invito", + calendarInviteCodePlaceholder: "Inserisci il codice di invito...", + calendarValidateInvite: "Convalida codice", calendarJoined: "Iscritto", calendarAddDate: "Aggiungi data", calendarAddNote: "Aggiungi nota", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_pt.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_pt.js index ae0fe4ec..fded2cc6 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_pt.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_pt.js @@ -37,7 +37,12 @@ module.exports = { " dos habitantes que apoias (incluindo do multiverso), ordenadas por data.", ], profile: "Avatar", - inhabitants: "Habitantes", + inhabitants: "Habitantes", + peersReplicatedFeeds: "Feeds replicados", + graphos: "Graphos", + graphosDescription: "Mapa interativo da rede ao seu redor.", + graphosYou: "Você", + graphosTotalNodes: "Total de nós", manualMode: "Modo manual", mentions: "Menções", mentionsDescription: [ @@ -2148,6 +2153,7 @@ module.exports = { bankAddressInvalid: 'Invalid address', bankAddressDeleted: 'Address deleted', bankAddressNotFound: 'Address not found', + bankAddressForbidden: 'Só podes configurar o teu próprio endereço de cobrança', bankAddressTotal: 'Total Addresses', bankAddressSearch: 'Search @inhabitant or address', bankAddressActions: 'Actions', @@ -2753,6 +2759,11 @@ module.exports = { fileTooLargeTitle: "Ficheiro demasiado grande", fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (50 MB). Por favor, seleciona um ficheiro mais pequeno.", goBack: "Voltar", + errorPageTitle: "Algo correu mal", + aiNavPlaceholder: "Para onde queres ir?", + aiNavDisabled: "A navegação por IA está desativada.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Módulo para consultas em linguagem natural sobre o conteúdo da rede.", directConnect: "Ligação direta", directConnectDescription: "Conecta-te diretamente a um par inserindo o endereço IP, porta e chave pública.", peerHost: "IP / Nome do anfitrião", @@ -2949,6 +2960,8 @@ module.exports = { gamesBackToGames: "Voltar aos Jogos", modulesGamesLabel: "Jogos", modulesGamesDescription: "Módulo para descobrir e jogar alguns jogos.", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "Módulo para explorar a rede como um mapa interativo de nós.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.", gamesTheFlowTitle: "ECOinflow", @@ -3018,6 +3031,9 @@ module.exports = { calendarCreated: "Criado", calendarAuthor: "Autor", calendarJoin: "Participar", + calendarGenerateInvite: "Gerar convite", + calendarInviteCodePlaceholder: "Digite o código de convite...", + calendarValidateInvite: "Validar código", calendarJoined: "Inscrito", calendarAddDate: "Adicionar data", calendarAddNote: "Adicionar nota", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ru.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ru.js index 5fb66fb1..46f93845 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ru.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_ru.js @@ -38,6 +38,11 @@ module.exports = { ], profile: "Аватар", inhabitants: "Жители", + peersReplicatedFeeds: "Реплицированные ленты", + graphos: "Графос", + graphosDescription: "Интерактивная карта сети вокруг вас.", + graphosYou: "Вы", + graphosTotalNodes: "Всего узлов", manualMode: "Ручной режим", mentions: "Упоминания", mentionsDescription: [ @@ -2113,6 +2118,7 @@ module.exports = { bankAddressInvalid: "Недействительный адрес", bankAddressDeleted: "Адрес удалён", bankAddressNotFound: "Адрес не найден", + bankAddressForbidden: "Вы можете задать только свой собственный адрес для выплат", bankAddressTotal: "Всего адресов", bankAddressSearch: "Поиск @жителя или адреса", bankAddressActions: "Действия", @@ -2707,6 +2713,11 @@ module.exports = { fileTooLargeTitle: "Файл слишком большой", fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.", goBack: "Назад", + errorPageTitle: "Что-то пошло не так", + aiNavPlaceholder: "Куда вы хотите перейти?", + aiNavDisabled: "Навигация с ИИ отключена.", + modulesAINavLabel: "AINav", + modulesAINavDescription: "Модуль для запросов на естественном языке к содержимому сети.", directConnect: "Прямое подключение", directConnectDescription: "Подключитесь напрямую к узлу, введя его IP-адрес, порт и публичный ключ. Узел будет добавлен как отслеживаемое соединение.", peerHost: "IP / Имя хоста", @@ -2911,6 +2922,8 @@ module.exports = { gamesBackToGames: "Назад к играм", modulesGamesLabel: "Игры", modulesGamesDescription: "Модуль для открытия и игры в игры.", + modulesGraphosLabel: "Графос", + modulesGraphosDescription: "Модуль для исследования сети в виде интерактивной карты узлов.", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.", gamesTheFlowTitle: "ECOinflow", @@ -2980,6 +2993,9 @@ module.exports = { calendarCreated: "Создан", calendarAuthor: "Автор", calendarJoin: "Присоединиться", + calendarGenerateInvite: "Создать приглашение", + calendarInviteCodePlaceholder: "Введите код приглашения...", + calendarValidateInvite: "Проверить код", calendarJoined: "Участник", calendarAddDate: "Добавить дату", calendarAddNote: "Добавить заметку", diff --git a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_zh.js b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_zh.js index ba19f1ff..6651c02d 100644 --- a/nodejs-project/nodejs-project/src/client/assets/translations/oasis_zh.js +++ b/nodejs-project/nodejs-project/src/client/assets/translations/oasis_zh.js @@ -38,6 +38,11 @@ module.exports = { ], profile: "头像", inhabitants: "居民", + peersReplicatedFeeds: "复制的 Feed", + graphos: "Graphos", + graphosDescription: "你周围网络的互动地图。", + graphosYou: "你", + graphosTotalNodes: "节点总数", manualMode: "手动模式", mentions: "提及", mentionsDescription: [ @@ -2149,6 +2154,7 @@ module.exports = { bankAddressInvalid: '无效地址', bankAddressDeleted: '地址已删除', bankAddressNotFound: '未找到地址', + bankAddressForbidden: '您只能设置自己的收款地址', bankAddressTotal: '地址总数', bankAddressSearch: '搜索 @居民或地址', bankAddressActions: '操作', @@ -2754,6 +2760,11 @@ module.exports = { fileTooLargeTitle: "文件过大", fileTooLargeMessage: "文件超过了允许的最大大小(50 MB)。请选择较小的文件。", goBack: "返回", + errorPageTitle: "出现了问题", + aiNavPlaceholder: "你想去哪里?", + aiNavDisabled: "AI 导航已停用。", + modulesAINavLabel: "AINav", + modulesAINavDescription: "用于以自然语言查询网络内容的模块。", directConnect: "直接连接", directConnectDescription: "通过输入对方的 IP 地址、端口和公钥直接连接到节点。该节点将被添加为已关注的连接。", peerHost: "IP / 主机名", @@ -2949,6 +2960,8 @@ module.exports = { gamesBackToGames: "返回游戏", modulesGamesLabel: "游戏", modulesGamesDescription: "用于发现和玩游戏的模块。", + modulesGraphosLabel: "Graphos", + modulesGraphosDescription: "将网络作为节点交互式地图浏览的模块。", gamesCocolandTitle: "Cocoland", gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。", gamesTheFlowTitle: "ECOinflow", @@ -3018,6 +3031,9 @@ module.exports = { calendarCreated: "已创建", calendarAuthor: "作者", calendarJoin: "加入", + calendarGenerateInvite: "生成邀请", + calendarInviteCodePlaceholder: "输入邀请码...", + calendarValidateInvite: "验证邀请码", calendarJoined: "已加入", calendarAddDate: "添加日期", calendarAddNote: "添加笔记", diff --git a/nodejs-project/nodejs-project/src/client/oasis_client.js b/nodejs-project/nodejs-project/src/client/oasis_client.js index ceb35bda..b4cd7d1d 100644 --- a/nodejs-project/nodejs-project/src/client/oasis_client.js +++ b/nodejs-project/nodejs-project/src/client/oasis_client.js @@ -23,7 +23,7 @@ const cli = (presets, defaultConfigFile) => }) .options("offline", { describe: - "Don't try to connect to scuttlebutt peers or pubs. This can be changed on the 'settings' page while Oasis is running.", + "Don't try to connect to Oasis peers or pubs. This can be changed on the 'settings' page while Oasis is running.", default: _.get(presets, "offline", false), type: "boolean", }) @@ -54,31 +54,6 @@ const cli = (presets, defaultConfigFile) => default: _.get(presets, "debug", false), type: "boolean", }) - .options("theme", { - describe: "The theme to use, if a theme hasn't been set in the cookies", - default: _.get(presets, "theme", "classic-light"), - type: "string", - }) - .options("wallet-url", { - describe: "The URL of the remote ECOin wallet", - default: _.get(presets, "walletUrl", "http://localhost:7474"), - type: "string", - }) - .options("wallet-user", { - describe: "The username of the remote ECOin wallet", - default: _.get(presets, "walletUser", "ecoinrpc"), - type: "string", - }) - .options("wallet-pass", { - describe: "The password of the remote ECOin wallet", - default: _.get(presets, "walletPass", "ecoinrpc"), - type: "string", - }) - .options("wallet-fee", { - describe: "The fee to pay for ECOin transactions", - default: _.get(presets, "walletFee", "0.01"), - type: "string", - }) .epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv; module.exports = { cli }; diff --git a/nodejs-project/nodejs-project/src/configs/config-manager.js b/nodejs-project/nodejs-project/src/configs/config-manager.js index 3e44606d..56e4e704 100644 --- a/nodejs-project/nodejs-project/src/configs/config-manager.js +++ b/nodejs-project/nodejs-project/src/configs/config-manager.js @@ -6,53 +6,55 @@ const configFilePath = path.join(__dirname, 'oasis-config.json'); if (!fs.existsSync(configFilePath)) { const defaultConfig = { "themes": { - "current": "OasisMobile" + "current": "Dark-SNH" }, "modules": { - "popularMod": "on", - "topicsMod": "off", - "summariesMod": "off", - "latestMod": "on", - "threadsMod": "on", - "multiverseMod": "on", - "invitesMod": "on", - "walletMod": "off", - "legacyMod": "off", - "cipherMod": "on", - "bookmarksMod": "on", - "videosMod": "on", - "docsMod": "on", - "audiosMod": "on", - "tagsMod": "on", - "imagesMod": "on", - "trendingMod": "on", - "eventsMod": "on", - "tasksMod": "off", - "marketMod": "on", - "votesMod": "on", - "tribesMod": "on", - "reportsMod": "off", - "opinionsMod": "on", - "transfersMod": "on", - "feedMod": "on", - "pixeliaMod": "off", - "agendaMod": "on", - "aiMod": "off", - "forumMod": "off", - "jobsMod": "on", - "projectsMod": "on", - "bankingMod": "on", - "parliamentMod": "off", - "courtsMod": "off", - "favoritesMod": "off", - "padsMod": "on", - "calendarsMod": "on", - "gamesMod": "on", - "shopsMod": "on", - "logsMod": "on", - "mapsMod": "on", - "chatsMod": "on", - "torrentsMod": "on" + "popularMod": "on", + "topicsMod": "on", + "summariesMod": "on", + "latestMod": "on", + "threadsMod": "on", + "multiverseMod": "on", + "invitesMod": "on", + "walletMod": "on", + "legacyMod": "on", + "cipherMod": "on", + "bookmarksMod": "on", + "videosMod": "on", + "docsMod": "on", + "audiosMod": "on", + "tagsMod": "on", + "imagesMod": "on", + "trendingMod": "on", + "eventsMod": "on", + "tasksMod": "on", + "marketMod": "on", + "votesMod": "on", + "tribesMod": "on", + "reportsMod": "on", + "opinionsMod": "on", + "padsMod": "on", + "calendarsMod": "on", + "transfersMod": "on", + "feedMod": "on", + "pixeliaMod": "on", + "agendaMod": "on", + "aiMod": "on", + "aiNavMod": "on", + "forumMod": "on", + "gamesMod": "on", + "jobsMod": "on", + "shopsMod": "on", + "projectsMod": "on", + "bankingMod": "on", + "parliamentMod": "on", + "courtsMod": "on", + "favoritesMod": "on", + "logsMod": "on", + "mapsMod": "on", + "chatsMod": "on", + "torrentsMod": "on", + "graphosMod": "on" }, "wallet": { "url": "http://localhost:7474", diff --git a/nodejs-project/nodejs-project/src/configs/oasis-config.json b/nodejs-project/nodejs-project/src/configs/oasis-config.json index 21105fe5..77d31a62 100644 --- a/nodejs-project/nodejs-project/src/configs/oasis-config.json +++ b/nodejs-project/nodejs-project/src/configs/oasis-config.json @@ -1,17 +1,17 @@ { "themes": { - "current": "OasisMobile" + "current": "Dark-SNH" }, "modules": { "popularMod": "on", - "topicsMod": "off", - "summariesMod": "off", + "topicsMod": "on", + "summariesMod": "on", "latestMod": "on", "threadsMod": "on", "multiverseMod": "on", "invitesMod": "on", - "walletMod": "off", - "legacyMod": "off", + "walletMod": "on", + "legacyMod": "on", "cipherMod": "on", "bookmarksMod": "on", "videosMod": "on", @@ -21,32 +21,34 @@ "imagesMod": "on", "trendingMod": "on", "eventsMod": "on", - "tasksMod": "off", + "tasksMod": "on", "marketMod": "on", "votesMod": "on", "tribesMod": "on", - "reportsMod": "off", + "reportsMod": "on", "opinionsMod": "on", - "transfersMod": "on", - "feedMod": "on", - "pixeliaMod": "off", - "agendaMod": "on", - "aiMod": "off", - "forumMod": "off", - "jobsMod": "on", - "projectsMod": "on", - "bankingMod": "on", - "parliamentMod": "off", - "courtsMod": "off", - "favoritesMod": "off", "padsMod": "on", "calendarsMod": "on", + "transfersMod": "on", + "feedMod": "on", + "pixeliaMod": "on", + "agendaMod": "on", + "aiMod": "on", + "aiNavMod": "on", + "forumMod": "on", "gamesMod": "on", + "jobsMod": "on", "shopsMod": "on", + "projectsMod": "on", + "bankingMod": "on", + "parliamentMod": "on", + "courtsMod": "on", + "favoritesMod": "on", "logsMod": "on", "mapsMod": "on", "chatsMod": "on", - "torrentsMod": "on" + "torrentsMod": "on", + "graphosMod": "on" }, "wallet": { "url": "http://localhost:7474", @@ -55,9 +57,7 @@ "fee": "5" }, "walletPub": { - "url": "", - "user": "", - "pass": "" + "pubId": "" }, "ai": { "prompt": "Provide an informative and precise response." @@ -69,4 +69,4 @@ "language": "en", "wish": "whole", "pmVisibility": "whole" -} \ No newline at end of file +} diff --git a/nodejs-project/nodejs-project/src/models/activity_model.js b/nodejs-project/nodejs-project/src/models/activity_model.js index 6bef213f..ca659fa5 100644 --- a/nodejs-project/nodejs-project/src/models/activity_model.js +++ b/nodejs-project/nodejs-project/src/models/activity_model.js @@ -1,7 +1,30 @@ const pull = require('../server/node_modules/pull-stream'); +const ssbRef = require('../server/node_modules/ssb-ref'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; +const safeFeedId = (v) => { + if (typeof v === 'string' && ssbRef.isFeed(v)) return v; + if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link; + return null; +}; + +const isContentSane = (c) => { + if (!c || typeof c !== 'object') return false; + if (c.type === 'contact') return !!safeFeedId(c.contact); + if (c.type === 'about') { + if (c.about === undefined) return true; + if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true; + return false; + } + if (c.type === 'pub') { + const addr = c.address; + if (!addr || typeof addr !== 'object') return false; + return typeof addr.key === 'string' && ssbRef.isFeed(addr.key); + } + return true; +}; + const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_'); const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD']; const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED']; @@ -21,6 +44,10 @@ function inferType(c = {}) { if (c.type === 'courts_nom_vote') return 'courtsNominationVote'; if (c.type === 'courts_public_pref') return 'courtsPublicPref'; if (c.type === 'courts_mediators') return 'courtsMediators'; + if (c.type === 'map') return 'map'; + if (c.type === 'mapMarker') return 'mapMarker'; + if (c.type === 'chat') return 'chat'; + if (c.type === 'chatMessage') return 'chatMessage'; if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') { const br = Array.isArray(c.branch) ? c.branch : []; if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread'; @@ -70,8 +97,10 @@ module.exports = ({ cooler }) => { const c = v?.content; if (!c?.type) continue; if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue } + if (!isContentSane(c)) continue; const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0; - idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c }); + const normalized = c.type === 'contact' ? { ...c, contact: safeFeedId(c.contact) } : c; + idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: normalized }); rawById.set(k, msg); if (c.replaces) parentOf.set(k, c.replaces); } @@ -220,148 +249,6 @@ module.exports = ({ cooler }) => { } } - if (type === 'tribe') { - const baseId = tip.id; - const baseTitle = (tip.content && tip.content.title) || ''; - const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false; - - const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length))); - const toSet = (xs) => new Set(uniq(xs)); - const excerpt2 = (s, max = 220) => { - const t = String(s || '').replace(/\s+/g, ' ').trim(); - return t.length > max ? t.slice(0, max - 1) + '…' : t; - }; - const feedMap = (feed) => { - const m = new Map(); - for (const it of (Array.isArray(feed) ? feed : [])) { - if (!it || typeof it !== 'object') continue; - const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : ''; - if (!id) continue; - m.set(id, it); - } - return m; - }; - - const sorted = arr - .filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object') - .sort((a, b) => (a.ts || 0) - (b.ts || 0)); - - let prev = null; - - for (const ev of sorted) { - if (!prev) { prev = ev; continue; } - - const prevMembers = toSet(prev.content.members); - const curMembers = toSet(ev.content.members); - const added = Array.from(curMembers).filter(x => !prevMembers.has(x)); - const removed = Array.from(prevMembers).filter(x => !curMembers.has(x)); - - for (const member of added) { - const overlayId = `${ev.id}:tribeJoin:${member}`; - idToAction.set(overlayId, { - id: overlayId, - author: member, - ts: ev.ts, - type: 'tribeJoin', - content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member } - }); - idToTipId.set(overlayId, overlayId); - } - - for (const member of removed) { - const overlayId = `${ev.id}:tribeLeave:${member}`; - idToAction.set(overlayId, { - id: overlayId, - author: member, - ts: ev.ts, - type: 'tribeLeave', - content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member } - }); - idToTipId.set(overlayId, overlayId); - } - - const prevFeed = feedMap(prev.content.feed); - const curFeed = feedMap(ev.content.feed); - - for (const [fid, item] of curFeed.entries()) { - if (prevFeed.has(fid)) continue; - const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author; - const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`; - idToAction.set(overlayId, { - id: overlayId, - author: feedAuthor, - ts: ev.ts, - type: 'tribeFeedPost', - content: { - type: 'tribeFeedPost', - tribeId: baseId, - tribeTitle: baseTitle, - isAnonymous, - feedId: fid, - date: item.date || ev.ts, - text: excerpt2(item.message || '') - } - }); - idToTipId.set(overlayId, overlayId); - } - - for (const [fid, curItem] of curFeed.entries()) { - const prevItem = prevFeed.get(fid); - if (!prevItem) continue; - - const pInh = toSet(prevItem.refeeds_inhabitants); - const cInh = toSet(curItem.refeeds_inhabitants); - const newInh = Array.from(cInh).filter(x => !pInh.has(x)); - - const curRefeeds = Number(curItem.refeeds || 0); - const prevRefeeds = Number(prevItem.refeeds || 0); - - const postText = excerpt2(curItem.message || ''); - - if (newInh.length) { - for (const who of newInh) { - const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`; - idToAction.set(overlayId, { - id: overlayId, - author: who, - ts: ev.ts, - type: 'tribeFeedRefeed', - content: { - type: 'tribeFeedRefeed', - tribeId: baseId, - tribeTitle: baseTitle, - isAnonymous, - feedId: fid, - text: postText - } - }); - idToTipId.set(overlayId, overlayId); - } - } else if (curRefeeds > prevRefeeds && ev.author) { - const who = ev.author; - const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`; - idToAction.set(overlayId, { - id: overlayId, - author: who, - ts: ev.ts, - type: 'tribeFeedRefeed', - content: { - type: 'tribeFeedRefeed', - tribeId: baseId, - tribeTitle: baseTitle, - isAnonymous, - feedId: fid, - text: postText - } - }); - idToTipId.set(overlayId, overlayId); - } - } - - prev = ev; - } - } - continue; } @@ -429,12 +316,13 @@ module.exports = ({ cooler }) => { a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || ''; a.content.rootKey = rootId || a.content.rootKey || ''; } - latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id }); + const actionRoot = rootOf(a.id); + latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null }); } let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id))); - const mediaTypes = new Set(['image','video','audio','document','bookmark']); + const mediaTypes = new Set(['image','video','audio','document','bookmark','map']); const perAuthorUnique = new Set(['karmaScore']); const byKey = new Map(); const norm = s => String(s || '').trim().toLowerCase(); @@ -488,14 +376,34 @@ module.exports = ({ cooler }) => { deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x }); - const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']); - const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type); + const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']); + const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']); + const isAllowedTribeActivity = (a) => { + if (tribeInternalTypes.has(a.type)) return false; + const c = a.content || {}; + if (c.tribeId) return false; + if (a.type === 'tribe') { + if (c.isAnonymous === true) return false; + const isInitial = !c.replaces; + if (!isInitial) return false; + } + return true; + }; + const isVisible = (a) => { + if (hiddenTypes.has(a.type)) return false; + if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false; + if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false; + if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false; + if (a.type === 'event' && String((a.content || {}).isPublic || '').toLowerCase() === 'private' && (a.content || {}).organizer !== userId && !(Array.isArray((a.content || {}).attendees) && (a.content || {}).attendees.includes(userId))) return false; + if (a.type === 'task' && String((a.content || {}).isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray((a.content || {}).assignees) && (a.content || {}).assignees.includes(userId))) return false; + return true; + }; let out; - if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a)); - else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a)) } - else if (filter === 'all') out = deduped.filter(isAllowedTribeActivity); - else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim'); + 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 === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim' || a.type === 'ubiclaimresult'); else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore'); 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'); @@ -510,6 +418,10 @@ module.exports = ({ cooler }) => { }); else if (filter === 'task') out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment'); + else if (filter === 'gameScore') out = deduped.filter(a => a.type === 'gameScore'); + 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.content || {}).status === 'OPEN'); + else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN'); else out = deduped.filter(a => a.type === filter); out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); diff --git a/nodejs-project/nodejs-project/src/models/banking_model.js b/nodejs-project/nodejs-project/src/models/banking_model.js index 1c9da5fc..74b72de2 100644 --- a/nodejs-project/nodejs-project/src/models/banking_model.js +++ b/nodejs-project/nodejs-project/src/models/banking_model.js @@ -713,35 +713,12 @@ async function getLastPublishedTimestamp(userId) { if (!pubId) throw new Error("no_pub_configured"); const alreadyClaimed = await hasClaimedThisMonth(uid); if (alreadyClaimed) throw new Error("already_claimed"); - const karmaScore = await getUserEngagementScore(uid); - const wMin = DEFAULT_RULES.caps.w_min; - const wMax = DEFAULT_RULES.caps.w_max; - const capUser = DEFAULT_RULES.caps.cap_user_epoch; - const userW = clamp(1 + karmaScore / 100, wMin, wMax); - const amount = Number(Math.max(1, Math.min(capUser * (userW / wMax), capUser)).toFixed(6)); const ssb = await openSsb(); if (!ssb || !ssb.publish) throw new Error("ssb_unavailable"); const now = new Date().toISOString(); - const transferContent = { - type: "transfer", - from: pubId, - to: uid, - concept: `UBI ${epochId} ${uid}`, - amount: String(amount), - createdAt: now, - updatedAt: now, - deadline: null, - confirmedBy: [pubId], - status: "UNCONFIRMED", - tags: ["UBI", "PENDING"], - opinions: {}, - opinions_inhabitants: [] - }; - const transferRes = await new Promise((resolve, reject) => ssb.publish(transferContent, (err, res) => err ? reject(err) : resolve(res))); - const transferId = transferRes?.key || ""; - const claimContent = { type: "ubiClaim", pubId, amount, epochId, claimedAt: now, transferId }; + const claimContent = { type: "ubiClaim", pubId, epochId, claimedAt: now }; await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res))); - return { status: "claimed_pending", amount, epochId }; + return { status: "claimed_pending", epochId }; } async function updateAllocationStatus(allocationId, status, txid) { diff --git a/nodejs-project/nodejs-project/src/models/calendars_model.js b/nodejs-project/nodejs-project/src/models/calendars_model.js index 0d4a98d4..46df0a09 100644 --- a/nodejs-project/nodejs-project/src/models/calendars_model.js +++ b/nodejs-project/nodejs-project/src/models/calendars_model.js @@ -1,6 +1,8 @@ const pull = require("../server/node_modules/pull-stream") +const crypto = require("crypto") const { getConfig } = require("../configs/config-manager.js") const logLimit = getConfig().ssbLogStream?.limit || 1000 +const INVITE_CODE_BYTES = 16 const safeText = (v) => String(v || "").trim() const normalizeTags = (raw) => { @@ -43,7 +45,46 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {} - const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {} + + const encryptStandalone = (content, rootId) => { + if (!tribeCrypto || !rootId) return content + const key = tribeCrypto.getKey(rootId) + if (!key) return content + return tribeCrypto.encryptContent(content, [key], true) + } + + const decryptCalendarRoot = (content, rootId) => { + if (!content || !content.encryptedPayload) return content + if (!tribeCrypto) return content + const keys = tribeCrypto.getKeys(rootId) + if (!keys || !keys.length) return { ...content, _undecryptable: true } + return tribeCrypto.decryptContent(content, keys.map(k => [k])) + } + + const decryptIndexNodes = async (idx) => { + if (!tribeCrypto) return + for (const [k, n] of idx.nodes.entries()) { + if (!n.c || !n.c.encryptedPayload) continue + let root = k + while (idx.parent.has(root)) root = idx.parent.get(root) + let dec = null + if (n.c.tribeId && tribesModel) { + try { + const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel) + if (r && !r._undecryptable) dec = r + } catch (_) {} + } + if (!dec) { + const r = decryptCalendarRoot(n.c, root) + if (r && !r._undecryptable) dec = r + } + if (dec) { + idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } }) + } else { + idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } }) + } + } + } const buildIndex = (messages) => { const tomb = new Set() @@ -95,6 +136,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { tags: Array.isArray(c.tags) ? c.tags : [], author: c.author || node.author, participants: Array.isArray(c.participants) ? c.participants : [], + invites: Array.isArray(c.invites) ? c.invites : [], createdAt: c.createdAt || new Date(node.ts).toISOString(), updatedAt: c.updatedAt || null, tribeId: c.tribeId || null, @@ -143,7 +185,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { 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 content = { + let plainContent = { type: "calendar", title: safeText(title), status: validStatus, @@ -151,53 +193,97 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { tags: normalizeTags(tags), author: userId, participants: [userId], + invites: [], createdAt: now, updatedAt: now, ...(tribeId ? { tribeId } : {}) } - content = await encryptIfTribe(content) + + let calKey = null + let content = plainContent + if (tribeId) { + content = await encryptIfTribe(plainContent) + } else if (tribeCrypto) { + calKey = tribeCrypto.generateTribeKey() + content = tribeCrypto.encryptContent(plainContent, [calKey], true) + } const calMsg = await new Promise((resolve, reject) => { ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) }) const calendarId = calMsg.key - const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly) - const allDateMsgs = [] - for (const d of dates) { - let dateContent = { - type: "calendarDate", + if (calKey && tribeCrypto) { + tribeCrypto.setKey(calendarId, calKey, 1) + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: calendarId, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()) + }) + } catch (_) {} + if (validStatus === "OPEN") { + try { + const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") + const ek = tribeCrypto.encryptForInvite(calKey, pubCode) + const tipId = await this.resolveCurrentId(calendarId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = decryptCalendarRoot(item.content, calendarId) + let updated = { + type: "calendar", + title: dec.title || "", + status: validStatus, + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: userId, + participants: [userId], + invites: [{ code: pubCode, ek, gen: 1, public: true }], + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + updated = encryptStandalone(updated, calendarId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())) + } catch (_) {} + } + } + + let dateContent = { + type: "calendarDate", + calendarId, + date: new Date(firstDate).toISOString(), + label: safeText(firstDateLabel), + author: userId, + createdAt: new Date().toISOString(), + ...(intervalWeekly ? { intervalWeekly: true } : {}), + ...(intervalMonthly ? { intervalMonthly: true } : {}), + ...(intervalYearly ? { intervalYearly: true } : {}), + ...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}), + ...(tribeId ? { tribeId } : {}) + } + if (tribeId) dateContent = await encryptIfTribe(dateContent) + else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true) + const dateMsg = await new Promise((resolve, reject) => { + ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg)) + }) + + if (firstNote && safeText(firstNote)) { + let noteContent = { + type: "calendarNote", calendarId, - date: d.toISOString(), - label: safeText(firstDateLabel), + dateId: dateMsg.key, + text: safeText(firstNote), author: userId, createdAt: new Date().toISOString(), ...(tribeId ? { tribeId } : {}) } - dateContent = await encryptIfTribe(dateContent) - const dateMsg = await new Promise((resolve, reject) => { - ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg)) + if (tribeId) noteContent = await encryptIfTribe(noteContent) + else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true) + await new Promise((resolve, reject) => { + ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) }) - allDateMsgs.push(dateMsg) - } - - if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) { - for (const dateMsg of allDateMsgs) { - let noteContent = { - type: "calendarNote", - calendarId, - dateId: dateMsg.key, - text: safeText(firstNote), - author: userId, - createdAt: new Date().toISOString(), - ...(tribeId ? { tribeId } : {}) - } - noteContent = await encryptIfTribe(noteContent) - await new Promise((resolve, reject) => { - ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) - }) - } } return calMsg @@ -205,13 +291,16 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { async updateCalendarById(id, data) { const tipId = await this.resolveCurrentId(id) + const rootId = await this.resolveRootId(id) const ssbClient = await openSsb() const userId = ssbClient.id const item = await new Promise((resolve, reject) => { ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it)) }) if (!item || !item.content) throw new Error("Calendar not found") - const oldDec = await decryptIfTribe(item.content) + const oldDec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, rootId) assertReadable(oldDec, "Calendar") if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author") let updated = { @@ -222,12 +311,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []), author: oldDec.author || userId, participants: oldDec.participants || [userId], + invites: Array.isArray(oldDec.invites) ? oldDec.invites : [], ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), createdAt: oldDec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } - updated = await encryptIfTribe(updated) + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, rootId) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) @@ -242,21 +333,29 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { if (!item || !item.content) throw new Error("Calendar not found") const dec = await decryptIfTribe(item.content) assertReadable(dec, "Calendar") - if ((dec.author || item.content.author) !== userId) throw new Error("Not the author") + const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author) + if (contentAuthor !== userId) throw new Error("Not the author") const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) }, async joinCalendar(calendarId) { const tipId = await this.resolveCurrentId(calendarId) + const rootId = await this.resolveRootId(calendarId) const ssbClient = await openSsb() 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 dec = await decryptIfTribe(item.content) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, rootId) assertReadable(dec, "Calendar") const participants = Array.isArray(dec.participants) ? dec.participants : [] if (participants.includes(userId)) return + if (tribeCrypto && Array.isArray(dec.invites)) { + const pub = dec.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)) + if (pub) return await this.joinByInvite(pub.code) + } let updated = { type: "calendar", title: dec.title || "", @@ -265,12 +364,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { tags: Array.isArray(dec.tags) ? dec.tags : [], author: dec.author, participants: [...participants, userId], + invites: Array.isArray(dec.invites) ? dec.invites : [], ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), createdAt: dec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } - updated = await encryptIfTribe(updated) + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, rootId) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) @@ -279,11 +380,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { async leaveCalendar(calendarId) { const tipId = await this.resolveCurrentId(calendarId) + const rootId = await this.resolveRootId(calendarId) const ssbClient = await openSsb() 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 dec = await decryptIfTribe(item.content) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, rootId) assertReadable(dec, "Calendar") if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave") const participants = Array.isArray(dec.participants) ? dec.participants : [] @@ -296,12 +400,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { tags: Array.isArray(dec.tags) ? dec.tags : [], author: dec.author, participants: participants.filter(p => p !== userId), + invites: Array.isArray(dec.invites) ? dec.invites : [], ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), createdAt: dec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } - updated = await encryptIfTribe(updated) + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, rootId) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) @@ -362,30 +468,33 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar") if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future") - const deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline - const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly) - const allMsgs = [] - for (const d of dates) { - let dateContent = { - type: "calendarDate", - calendarId: rootId, - date: d.toISOString(), - label: safeText(label), - author: userId, - createdAt: new Date().toISOString(), - ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) - } - dateContent = await encryptIfTribe(dateContent) - const msg = await new Promise((resolve, reject) => { - ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m)) - }) - allMsgs.push(msg) + const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) + const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : "" + let dateContent = { + type: "calendarDate", + calendarId: rootId, + date: new Date(date).toISOString(), + label: safeText(label), + author: userId, + createdAt: new Date().toISOString(), + ...(intervalWeekly ? { intervalWeekly: true } : {}), + ...(intervalMonthly ? { intervalMonthly: true } : {}), + ...(intervalYearly ? { intervalYearly: true } : {}), + ...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}), + ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) } - return allMsgs + if (cal.tribeId) dateContent = await encryptIfTribe(dateContent) + else dateContent = encryptStandalone(dateContent, rootId) + const msg = await new Promise((resolve, reject) => { + ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m)) + }) + return [msg] }, async getDatesForCalendar(calendarId) { const rootId = await this.resolveRootId(calendarId) + const cal = await this.getCalendarById(rootId) + const calDeadline = cal && cal.deadline ? cal.deadline : "" const ssbClient = await openSsb() const messages = await readAll(ssbClient) const authorByKey = new Map() @@ -411,14 +520,23 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { dec = r && !r._undecryptable ? r : c if (r && r._undecryptable) continue } - dates.push({ + const baseEntry = { key: m.key, calendarId: dec.calendarId || c.calendarId, - date: dec.date, label: dec.label || "", author: dec.author || v.author, createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString() - }) + } + const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly) + const ruleDeadline = dec.intervalDeadline || calDeadline + if (hasInterval && ruleDeadline) { + const occurrences = expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly) + for (const occ of occurrences) { + dates.push({ ...baseEntry, date: occ.toISOString() }) + } + } else { + dates.push({ ...baseEntry, date: dec.date }) + } } dates.sort((a, b) => new Date(a.date) - new Date(b.date)) return dates @@ -487,7 +605,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { createdAt: new Date().toISOString(), ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) } - noteContent = await encryptIfTribe(noteContent) + if (cal.tribeId) noteContent = await encryptIfTribe(noteContent) + else noteContent = encryptStandalone(noteContent, rootId) return new Promise((resolve, reject) => { ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) }) @@ -555,7 +674,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { for (const m of messages) { const c = (m.value || {}).content if (!c || c.type !== "calendarReminderSent") continue - sentMarkers.add(`${c.calendarId}::${c.dateId}`) + const sig = c.occurrence ? `${c.calendarId}::${c.dateId}::${c.occurrence}` : `${c.calendarId}::${c.dateId}` + sentMarkers.add(sig) } const authorByKey = new Map() @@ -569,6 +689,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { } } + const calendarDeadlines = new Map() const dueByCalendar = new Map() for (const m of messages) { if (tombstoned.has(m.key)) continue @@ -581,21 +702,42 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { if (!r || r._undecryptable) continue dec = r } - if (!dec.date || new Date(dec.date).getTime() > now) continue - if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue - const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" } - const list = dueByCalendar.get(c.calendarId) || [] - list.push(entry) - dueByCalendar.set(c.calendarId, list) + if (!dec.date) continue + const calId = c.calendarId + let calDeadline = calendarDeadlines.get(calId) + if (calDeadline === undefined) { + try { + const cc = await this.getCalendarById(calId) + calDeadline = (cc && cc.deadline) || "" + } catch (_) { calDeadline = "" } + calendarDeadlines.set(calId, calDeadline) + } + const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly) + const ruleDeadline = dec.intervalDeadline || calDeadline + const occurrences = (hasInterval && ruleDeadline) + ? expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly) + : [new Date(dec.date)] + for (const occ of occurrences) { + if (occ.getTime() > now) continue + const occIso = occ.toISOString() + const sig = hasInterval ? `${calId}::${m.key}::${occIso}` : `${calId}::${m.key}` + if (sentMarkers.has(sig)) continue + const entry = { key: m.key, calendarId: calId, date: occIso, label: dec.label || "", recurring: hasInterval } + const list = dueByCalendar.get(calId) || [] + list.push(entry) + dueByCalendar.set(calId, list) + } } - const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => { - ssbClient.publish({ + const publishMarker = (calendarId, dateId, occurrence) => new Promise((resolve, reject) => { + const payload = { type: "calendarReminderSent", calendarId, dateId, sentAt: new Date().toISOString() - }, (err) => err ? reject(err) : resolve()) + } + if (occurrence) payload.occurrence = occurrence + ssbClient.publish(payload, (err) => err ? reject(err) : resolve()) }) for (const [calendarId, list] of dueByCalendar.entries()) { @@ -623,10 +765,134 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => { } } for (const dd of list) { - try { await publishMarker(calendarId, dd.key) } catch (_) {} + try { await publishMarker(calendarId, dd.key, dd.recurring ? dd.date : null) } catch (_) {} } } catch (_) {} } + }, + + async generateInvite(calendarId) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const cal = await this.getCalendarById(calendarId) + if (!cal) throw new Error("Calendar not found") + if (cal.author !== userId) throw new Error("Only the author can generate invites") + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") + let invite = code + if (tribeCrypto && !cal.tribeId) { + const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code) + if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(cal.rootId) } + } + const tipId = await this.resolveCurrentId(calendarId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, cal.rootId) + const invites = [...(Array.isArray(dec.invites) ? dec.invites : []), invite] + let updated = { + type: "calendar", + title: dec.title || "", + status: dec.status || "OPEN", + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: dec.author, + participants: Array.isArray(dec.participants) ? dec.participants : [userId], + invites, + ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, cal.rootId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + return code + }, + + async joinByInvite(code) { + const ssbClient = await openSsb() + const userId = ssbClient.id + const calendars = await this.listAll() + let matched = null + let matchedInvite = null + for (const cal of calendars) { + const invs = Array.isArray(cal.invites) ? cal.invites : [] + for (const inv of invs) { + if (typeof inv === "string" && inv === code) { matched = cal; matchedInvite = inv; break } + if (typeof inv === "object" && inv.code === code) { matched = cal; matchedInvite = inv; break } + } + if (matched) break + } + if (!matched) throw new Error("Invalid or expired invite code") + if (matched.participants.includes(userId)) throw new Error("Already a participant") + let calKey = null + if (tribeCrypto && typeof matchedInvite === "object") { + if (matchedInvite.ekChain) { + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code) + if (Array.isArray(chain) && chain.length) { + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length) + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1) + } + } + calKey = chain[0].key + } + } else if (matchedInvite.ek) { + calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code) + tribeCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1) + } + } + const tipId = await this.resolveCurrentId(matched.rootId) + const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) + const dec = item.content.tribeId + ? await decryptIfTribe(item.content) + : decryptCalendarRoot(item.content, matched.rootId) + const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true + const invites = isPublicInvite + ? (Array.isArray(dec.invites) ? dec.invites : []) + : (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => { + if (typeof inv === "string") return inv !== code + return inv.code !== code + }) + let updated = { + type: "calendar", + title: dec.title || "", + status: dec.status || "OPEN", + deadline: dec.deadline || "", + tags: Array.isArray(dec.tags) ? dec.tags : [], + author: dec.author, + participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId], + invites, + ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), + createdAt: dec.createdAt, + updatedAt: new Date().toISOString(), + replaces: tipId + } + if (item.content.tribeId) updated = await encryptIfTribe(updated) + else updated = encryptStandalone(updated, matched.rootId) + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) + const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } + await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) + if (tribeCrypto && calKey) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {} + if (matched.author && matched.author !== userId) { + try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: matched.rootId, generation: tribeCrypto.getGen(matched.rootId) || 1, memberKeys }, () => resolve()) + }) + } + } catch (_) {} + } + return matched.rootId } } } diff --git a/nodejs-project/nodejs-project/src/models/chats_model.js b/nodejs-project/nodejs-project/src/models/chats_model.js index 24301f51..656b6f00 100644 --- a/nodejs-project/nodejs-project/src/models/chats_model.js +++ b/nodejs-project/nodejs-project/src/models/chats_model.js @@ -192,18 +192,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { ...(tribeId ? { tribeId } : {}) } - if (tribeCrypto && !tribeId) { - const chatKey = tribeCrypto.generateTribeKey() - const result = await new Promise((resolve, reject) => { + if (!tribeCrypto) { + return new Promise((resolve, reject) => { ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) }) - tribeCrypto.setKey(result.key, chatKey, 1) - return result } - return new Promise((resolve, reject) => { + if (tribeId) { + try { + const ancestryIds = await tribesModel.getAncestryChain(tribeId) + const chain = [] + for (const rid of ancestryIds || []) { + const k = tribeCrypto.getKey(rid) + if (!k) { chain.length = 0; break } + chain.push(k) + } + if (chain.length) content = tribeCrypto.encryptContent(content, chain, true) + } catch (_) {} + return new Promise((resolve, reject) => { + ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) + }) + } + + const chatKey = tribeCrypto.generateTribeKey() + if (st === "OPEN") { + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") + const ek = tribeCrypto.encryptForInvite(chatKey, code) + content.invites = [{ code, ek, gen: 1, public: true }] + } + content = tribeCrypto.encryptContent(content, [chatKey], true) + const result = await new Promise((resolve, reject) => { ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) }) + tribeCrypto.setKey(result.key, chatKey, 1) + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()) + }) + } catch (_) {} + return result }, async updateChatById(id, data, { skipAuthorCheck = false } = {}) { @@ -211,40 +240,60 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { const ssbClient = await openSsb() const userId = ssbClient.id + const item = await new Promise((resolve, reject) => { + ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item)) + }) + const c = item.content + const rawAuthor = c.author || (c.encryptedPayload ? null : undefined) + if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author") + + const messages = await readAll(ssbClient) + const idx = buildIndex(messages) + let rootId = tipId + while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId) + const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 } + const chat = buildChat(node, rootId) + if (!chat) throw new Error("Invalid chat") + + let updated = { + type: "chat", + replaces: tipId, + title: data.title !== undefined ? safeText(data.title) : chat.title, + description: data.description !== undefined ? safeText(data.description) : chat.description, + image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image, + category: data.category !== undefined ? safeText(data.category) : chat.category, + status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status, + tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags, + members: data.members !== undefined ? safeArr(data.members) : chat.members, + invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites, + author: chat.author, + createdAt: chat.createdAt, + updatedAt: new Date().toISOString(), + ...(chat.tribeId ? { tribeId: chat.tribeId } : {}) + } + + if (tribeCrypto) { + if (chat.tribeId) { + try { + const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId) + const chain = [] + for (const rid of ancestryIds || []) { + const k = tribeCrypto.getKey(rid) + if (!k) { chain.length = 0; break } + chain.push(k) + } + if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true) + } catch (_) {} + } else { + const chatKey = tribeCrypto.getKey(rootId) + if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true) + } + } + return new Promise((resolve, reject) => { - ssbClient.get(tipId, (err, item) => { - if (err || !item?.content) return reject(new Error("Chat not found")) - const c = item.content - - const rawAuthor = c.author || (c.encryptedPayload ? null : undefined) - if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author")) - - const rootId = tipId - const messages = [] - const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 } - const chat = buildChat(node, rootId) - if (!chat) return reject(new Error("Invalid chat")) - - const updated = { - type: "chat", - replaces: tipId, - title: data.title !== undefined ? safeText(data.title) : chat.title, - description: data.description !== undefined ? safeText(data.description) : chat.description, - image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image, - category: data.category !== undefined ? safeText(data.category) : chat.category, - status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status, - tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags, - members: data.members !== undefined ? safeArr(data.members) : chat.members, - invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites, - author: chat.author, - createdAt: chat.createdAt, - updatedAt: new Date().toISOString() - } - - ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => { - if (e1) return reject(e1) - ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res)) - }) + ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => { + if (e1) return reject(e1) + ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res)) }) }) }, @@ -372,10 +421,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { let invite = code if (tribeCrypto) { - const chatKey = tribeCrypto.getKey(chat.rootId) - if (chatKey) { - const ek = tribeCrypto.encryptForInvite(chatKey, code) - invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) } + const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code) + if (ekChain) { + invite = { code, ekChain, gen: tribeCrypto.getGen(chat.rootId) } + } else { + const chatKey = tribeCrypto.getKey(chat.rootId) + if (chatKey) { + const ek = tribeCrypto.encryptForInvite(chatKey, code) + invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) } + } } } @@ -414,18 +468,51 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { if (!matchedChat) throw new Error("Invalid or expired invite code") if (matchedChat.members.includes(userId)) throw new Error("Already a participant") - if (tribeCrypto && typeof matchedInvite === "object" && matchedInvite.ek) { - const chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code) - tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1) + let chatKey = null + if (tribeCrypto && typeof matchedInvite === "object") { + if (matchedInvite.ekChain) { + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code) + if (Array.isArray(chain) && chain.length) { + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length) + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1) + } + } + chatKey = chain[0].key + } + } else if (matchedInvite.ek) { + chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code) + tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1) + } } const members = [...matchedChat.members, userId] - const invites = matchedChat.invites.filter(inv => { + const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true + const invites = isPublicInvite ? matchedChat.invites : matchedChat.invites.filter(inv => { if (typeof inv === "string") return inv !== code return inv.code !== code }) await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true }) + + if (tribeCrypto && chatKey) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {} + if (matchedChat.author && matchedChat.author !== userId) { + try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: tribeCrypto.getGen(matchedChat.rootId) || 1, memberKeys }, () => resolve()) + }) + } + } catch (_) {} + } + return matchedChat.key }, @@ -437,17 +524,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { if (chat.status === "CLOSED") throw new Error("Chat is closed") if (chat.members.includes(userId)) return chat.key - const members = [...chat.members, userId] - - if (tribeCrypto) { - const chatKey = tribeCrypto.getKey(chat.rootId) - if (chatKey && ssbClient.keys) { - try { - tribeCrypto.boxKeyForMember(chatKey, userId, ssbClient.keys) - } catch (_) {} - } + if (tribeCrypto && Array.isArray(chat.invites)) { + const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)) + if (pub) return await this.joinByInvite(pub.code) } + const members = [...chat.members, userId] await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true }) return chat.key }, diff --git a/nodejs-project/nodejs-project/src/models/main_models.js b/nodejs-project/nodejs-project/src/models/main_models.js index 06718f23..8cdfea7e 100644 --- a/nodejs-project/nodejs-project/src/models/main_models.js +++ b/nodejs-project/nodejs-project/src/models/main_models.js @@ -14,6 +14,7 @@ const fs = require('fs/promises'); const os = require('os'); const ssbRef = require("../server/node_modules/ssb-ref"); +const nameCache = require('../backend/nameCache'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; @@ -63,7 +64,7 @@ const publicOnlyFilter = pull.filter(isNotPrivate); const configure = (...customOptions) => Object.assign({}, defaultOptions, ...customOptions); -// peers +// PEERS const ebtDir = path.join(os.homedir(), '.ssb', 'ebt'); const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json'); @@ -117,13 +118,10 @@ const canonicalizePubId = (s) => { }; const parseRemote = (remote) => { - // net: format (TCP) let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote); if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) }; - // ws/wss format (WebSocket) m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote); if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) }; - // Generic: extract ~shs: part from any format m = /~shs:([^=]+)=/.exec(remote); if (m) return { host: null, pubId: canonicalizePubId(m[1]) }; return { host: null, pubId: null }; @@ -161,7 +159,7 @@ function toLegacyInvite(s) { return `${m[1]}:${m[2]}:@${key}~${m[4]}`; } -// core modules +// CORE MODEL module.exports = ({ cooler, isPublic }) => { const models = {}; const getAbout = async ({ key, feedId }) => { @@ -290,7 +288,7 @@ module.exports = ({ cooler, isPublic }) => { ); }; -//ABOUT MODEL +// ABOUT MODEL models.about = { publicWebHosting: async (feedId) => { const result = await getAbout({ @@ -303,12 +301,16 @@ models.about = { if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { return "Redacted"; } - return ( - (await getAbout({ - key: "name", - feedId, - })) || feedId.slice(1, 1 + 8) - ); + const resolved = await getAbout({ key: "name", feedId }); + if (resolved) nameCache.set(feedId, resolved, Date.now()); + return resolved || feedId.slice(1, 1 + 8); + }, + nameSync: (feedId) => { + if (!feedId) return null; + const cached = nameCache.get(feedId); + if (cached) return cached; + const local = feeds_to_name[feedId]; + return local && local.name ? local.name : null; }, named: (name) => { let found = []; @@ -397,9 +399,11 @@ models.about = { if (typeof currentEntry == "undefined") { dirty = true; feeds_to_name[feed] = newEntry; + nameCache.set(feed, name, ts); } else if (currentEntry.ts < ts) { dirty = true; feeds_to_name[feed] = newEntry; + nameCache.set(feed, name, ts); } }, (err) => { console.error(err); @@ -476,7 +480,7 @@ models.blob = { } }; -//FRIENDS MODEL +// FRIENDS MODEL models.friend = { setRelationship: async ({ feedId, following, blocking }) => { if (following && blocking) { @@ -566,7 +570,7 @@ models.friend = { }, }; -//META MODEL +// META MODEL models.meta = { myFeedId: async () => { const ssb = await cooler.open(); @@ -601,14 +605,97 @@ models.meta = { pull.take(1), pull.collect((err, [entries]) => { if (err) return reject(err); - resolve(entries); + resolve(entries || []); }) ); }); }, connectedPeers: async () => { - const peers = await models.meta.peers(); - return peers.filter(([_, data]) => data.state === "connected"); + const ssb = await cooler.open(); + const connEntries = await models.meta.peers(); + const seen = new Set(); + const result = []; + + const lookupAddr = (key) => { + for (const [addr, data] of connEntries) { + if (data && data.key === key) return addr; + } + return null; + }; + + const lookupConnData = (key) => { + for (const [, data] of connEntries) { + if (data && data.key === key) return data; + } + return null; + }; + + try { + const livePeers = ssb && ssb.peers && typeof ssb.peers === "object" ? ssb.peers : {}; + for (const rawKey of Object.keys(livePeers)) { + if (!rawKey || rawKey === ssb.id) continue; + const rpcs = livePeers[rawKey]; + if (!Array.isArray(rpcs) || rpcs.length === 0) continue; + const key = canonicalizePubId(rawKey); + if (seen.has(key)) continue; + seen.add(key); + const existing = lookupConnData(key) || {}; + const addr = (rpcs[0] && rpcs[0].stream && rpcs[0].stream.address) || lookupAddr(key) || `live:${key}`; + result.push([addr, { ...existing, key, state: "connected", source: "rpc" }]); + } + } catch {} + + for (const [addr, data] of connEntries) { + if (!data || data.state !== "connected" || !data.key || seen.has(data.key)) continue; + seen.add(data.key); + result.push([addr, data]); + } + + try { + const gp = ssb.gossip && typeof ssb.gossip.peers === "function" ? ssb.gossip.peers() : []; + const RECENT_MS = 30 * 60 * 1000; + const now = Date.now(); + for (const p of (gp || [])) { + if (!p || !p.key) continue; + const key = canonicalizePubId(p.key); + if (seen.has(key)) continue; + const isConnected = p.state === "connected"; + const recentlyConnected = + !isConnected && + (p.failure === 0 || p.failure === undefined || p.failure === null) && + typeof p.stateChange === "number" && + (now - p.stateChange) < RECENT_MS; + if (!isConnected && !recentlyConnected) continue; + let addr = p.address; + if (!addr && p.host && p.port) { + const core = String(p.key).replace(/^@/, "").replace(/\.ed25519$/, ""); + addr = `net:${p.host}:${p.port}~shs:${core}`; + } + if (!addr) continue; + seen.add(key); + result.push([addr, { ...p, key, state: "connected", source: isConnected ? "gossip" : "recent" }]); + } + } catch {} + + try { + const myId = ssb.id; + const status = ssb.ebt && typeof ssb.ebt.peerStatus === "function" ? ssb.ebt.peerStatus(myId) : null; + const ebtPeers = (status && status.peers) ? Object.keys(status.peers) : []; + for (const rawKey of ebtPeers) { + if (!rawKey) continue; + const key = canonicalizePubId(rawKey); + if (seen.has(key)) continue; + let addr = lookupAddr(key); + if (!addr) { + const core = String(key).replace(/^@/, "").replace(/\.ed25519$/, ""); + addr = `ebt:${core}`; + } + seen.add(key); + result.push([addr, { key, state: "connected", source: "ebt" }]); + } + } catch {} + + return result; }, onlinePeers: async () => { const entries = await models.meta.connectedPeers(); @@ -617,7 +704,6 @@ models.meta = { discovered: async () => { const ssb = await cooler.open(); const snapshot = await ssb.conn.dbPeers(); - // Read gossip.json to merge announcers data const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json'); let gossipMap = new Map(); try { @@ -629,7 +715,6 @@ models.meta = { } } catch {} const allDbPeers = await enrichEntries(snapshot); - // Merge announcers from gossip.json into enriched peers for (const [, peerData] of allDbPeers) { if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) { const gossipEntry = gossipMap.get(peerData.key); @@ -637,15 +722,13 @@ models.meta = { } } const connectedEntries = await models.meta.connectedPeers(); - const onlineKeys = new Set(connectedEntries.map(([remote]) => { - const m = /~shs:([^=]+)=/.exec(remote); - if (!m) return null; - let core = m[1].replace(/-/g, '+').replace(/_/g, '/'); - if (!core.endsWith('=')) core += '='; - return `@${core}.ed25519`; - }).filter(Boolean)); - const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(d.key)); - const discoveredIds = new Set(allDbPeers.map(([, d]) => d.key)); + const onlineKeys = new Set( + connectedEntries + .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null) + .filter(Boolean) + ); + const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(canonicalizePubId(d.key))); + const discoveredIds = new Set(allDbPeers.map(([, d]) => canonicalizePubId(d.key))); const ebtList = await loadPeersFromEbt(); const ebtMap = new Map(ebtList.map(e => [e.pub, e.users])); const unknownPeers = []; @@ -723,9 +806,7 @@ models.meta = { const ssb = await cooler.open(); const code = toLegacyInvite(String(invite || '')); const result = await new Promise((resolve, reject) => { - ssb.invite.accept(code, (err, res) => { - err ? reject(err) : resolve(res); - }); + ssb.invite.accept(code, (err, res) => err ? reject(err) : resolve(res)); }); const { host, pubId } = parseRemote(code); await new Promise((resolve) => { @@ -734,7 +815,7 @@ models.meta = { pubHost: host || null, pubKey: pubId || null, acceptedAt: new Date().toISOString(), - }, (err) => resolve()); + }, (_err) => resolve()); }); return result; }, @@ -1954,7 +2035,5 @@ models.vote = { }); }, }; - -//return models return models; }; diff --git a/nodejs-project/nodejs-project/src/models/maps_model.js b/nodejs-project/nodejs-project/src/models/maps_model.js index e0b5fe5a..3af9a22e 100644 --- a/nodejs-project/nodejs-project/src/models/maps_model.js +++ b/nodejs-project/nodejs-project/src/models/maps_model.js @@ -1,7 +1,9 @@ const pull = require("../server/node_modules/pull-stream"); +const crypto = require("crypto"); const { getConfig } = require("../configs/config-manager.js"); const logLimit = getConfig().ssbLogStream?.limit || 1000; +const INVITE_CODE_BYTES = 16; const safeArr = (v) => (Array.isArray(v) ? v : []); @@ -25,7 +27,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c; const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c; const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}; - const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}; + + const encryptStandalone = (content, rootId) => { + if (!tribeCrypto || !rootId) return content; + const key = tribeCrypto.getKey(rootId); + if (!key) return content; + return tribeCrypto.encryptContent(content, [key], true); + }; + + const decryptMapRoot = (content, rootId) => { + if (!content || !content.encryptedPayload) return content; + if (!tribeCrypto) return content; + const keys = tribeCrypto.getKeys(rootId); + if (!keys || !keys.length) return { ...content, _undecryptable: true }; + return tribeCrypto.decryptContent(content, keys.map(k => [k])); + }; + + const decryptIndexNodes = async (idx) => { + if (!tribeCrypto) return; + for (const [k, n] of (idx.nodes ? idx.nodes.entries() : [])) { + if (!n.c || !n.c.encryptedPayload) continue; + let root = k; + if (idx.parent) { while (idx.parent.has(root)) root = idx.parent.get(root); } + else if (idx.backward) { while (idx.backward.has(root)) root = idx.backward.get(root); } + let dec = null; + if (n.c.tribeId && tribesModel) { + try { + const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel); + if (r && !r._undecryptable) dec = r; + } catch (_) {} + } + if (!dec) { + const r = decryptMapRoot(n.c, root); + if (r && !r._undecryptable) dec = r; + } + if (dec) { + idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } }); + } else { + idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } }); + } + } + }; const getAllMessages = async (ssbClient) => new Promise((resolve, reject) => { @@ -157,6 +199,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE", tags: safeArr(c.tags), author: c.author, + members: Array.isArray(c.members) ? c.members : [], + invites: Array.isArray(c.invites) ? c.invites : [], tribeId: c.tribeId || null, encrypted: !!undec, createdAt: c.createdAt || new Date(node.ts).toISOString(), @@ -195,11 +239,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { async createMap(lat, lng, description, mapType, tagsRaw, title, tribeId, markerLabel, image) { const ssbClient = await openSsb(); + const userId = ssbClient.id; const tags = normalizeTags(tagsRaw) || []; const now = new Date().toISOString(); const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE"; - let content = { + let plainContent = { type: "map", title: title || "", lat: parseFloat(lat) || 0, @@ -207,7 +252,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { description: description || "", markerLabel: markerLabel || "", mapType: mType, - author: ssbClient.id, + author: userId, + members: [userId], + invites: [], tags, ...(tribeId ? { tribeId } : {}), ...(image ? { image } : {}), @@ -215,21 +262,71 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { updatedAt: now }; - content = await encryptIfTribe(content); + const shouldEncryptStandalone = !tribeId && tribeCrypto && (mType === "OPEN" || mType === "CLOSED"); + let mapKey = null; + let content = plainContent; + if (tribeId) { + content = await encryptIfTribe(plainContent); + } else if (shouldEncryptStandalone) { + mapKey = tribeCrypto.generateTribeKey(); + content = tribeCrypto.encryptContent(plainContent, [mapKey], true); + } - return new Promise((resolve, reject) => { + const result = await new Promise((resolve, reject) => { ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); }); + + if (mapKey) { + tribeCrypto.setKey(result.key, mapKey, 1); + try { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()); + }); + } catch (_) {} + if (mType === "OPEN") { + try { + const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex"); + const ek = tribeCrypto.encryptForInvite(mapKey, pubCode); + let updated = { + type: "map", + replaces: result.key, + title: plainContent.title, + lat: plainContent.lat, + lng: plainContent.lng, + description: plainContent.description, + markerLabel: plainContent.markerLabel, + mapType: mType, + author: userId, + members: [userId], + invites: [{ code: pubCode, ek, gen: 1, public: true }], + tags, + ...(image ? { image } : {}), + createdAt: now, + updatedAt: new Date().toISOString() + }; + updated = encryptStandalone(updated, result.key); + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: result.key, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())); + } catch (_) {} + } + } + + return result; }, async updateMapById(id, lat, lng, description, mapType, tagsRaw, title, image) { const ssbClient = await openSsb(); const userId = ssbClient.id; const tipId = await this.resolveCurrentId(id); + const rootId = await this.resolveRootId(id); const oldMsg = await getMsg(ssbClient, tipId); if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found"); - const oldDecrypted = await decryptIfTribe(oldMsg.content); + const oldDecrypted = oldMsg.content.tribeId + ? await decryptIfTribe(oldMsg.content) + : decryptMapRoot(oldMsg.content, rootId); assertReadable(oldDecrypted, "Map"); if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author"); @@ -248,13 +345,19 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { mapType: mType, tags, author: oldDecrypted.author || userId, + members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId], + invites: Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [], ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}), ...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})), createdAt: oldDecrypted.createdAt, updatedAt: now }; - updated = await encryptIfTribe(updated); + if (oldMsg.content.tribeId) { + updated = await encryptIfTribe(updated); + } else if (mType !== "SINGLE") { + updated = encryptStandalone(updated, rootId); + } const result = await new Promise((resolve, reject) => { ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res))); @@ -303,9 +406,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers"); const now = new Date().toISOString(); + let rootId = tipId; + while (idx.backward && idx.backward.has(rootId)) rootId = idx.backward.get(rootId); let content = { type: "mapMarker", - mapId: tipId, + mapId: rootId, lat: parseFloat(lat) || 0, lng: parseFloat(lng) || 0, label: label || "", @@ -315,7 +420,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { }; if (image) content.image = image; - content = await encryptIfTribe(content); + if (node.c.tribeId) { + content = await encryptIfTribe(content); + } else if (tribeCrypto) { + const mapKey = tribeCrypto.getKey(rootId); + if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true); + } return new Promise((resolve, reject) => { ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); @@ -395,6 +505,150 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root))); return buildMap(node, root, viewer, markerList); + }, + + async generateInvite(mapId) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (map.author !== userId) throw new Error("Only the author can generate invites"); + const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex"); + let invite = code; + if (tribeCrypto && !map.tribeId) { + const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code); + if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(map.rootId || map.key) || 1 }; + } + const tipId = await this.resolveCurrentId(mapId); + const rootId = await this.resolveRootId(mapId); + const oldMsg = await getMsg(ssbClient, tipId); + const oldDecrypted = oldMsg.content.tribeId + ? await decryptIfTribe(oldMsg.content) + : decryptMapRoot(oldMsg.content, rootId); + const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite]; + let updated = { + type: "map", + replaces: tipId, + title: oldDecrypted.title || "", + lat: oldDecrypted.lat, + lng: oldDecrypted.lng, + description: oldDecrypted.description || "", + markerLabel: oldDecrypted.markerLabel || "", + mapType: oldDecrypted.mapType, + tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [], + author: oldDecrypted.author, + members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId], + invites, + ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}), + ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}), + createdAt: oldDecrypted.createdAt, + updatedAt: new Date().toISOString() + }; + if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated); + else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId); + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())); + return code; + }, + + async joinByInvite(code) { + const ssbClient = await openSsb(); + const userId = ssbClient.id; + const maps = await this.listAll({ filter: "all", viewerId: userId }); + let matched = null; + let matchedInvite = null; + for (const m of maps) { + const invs = Array.isArray(m.invites) ? m.invites : []; + for (const inv of invs) { + if (typeof inv === "string" && inv === code) { matched = m; matchedInvite = inv; break; } + if (typeof inv === "object" && inv.code === code) { matched = m; matchedInvite = inv; break; } + } + if (matched) break; + } + if (!matched) throw new Error("Invalid or expired invite code"); + if (Array.isArray(matched.members) && matched.members.includes(userId)) throw new Error("Already a member"); + let mapKey = null; + if (tribeCrypto && typeof matchedInvite === "object") { + if (matchedInvite.ekChain) { + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code); + if (Array.isArray(chain) && chain.length) { + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length); + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1); + } + } + mapKey = chain[0].key; + } + } else if (matchedInvite.ek) { + mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code); + tribeCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1); + } + } + const tipId = await this.resolveCurrentId(matched.rootId || matched.key); + const rootId = await this.resolveRootId(matched.rootId || matched.key); + const oldMsg = await getMsg(ssbClient, tipId); + const oldDecrypted = oldMsg.content.tribeId + ? await decryptIfTribe(oldMsg.content) + : decryptMapRoot(oldMsg.content, rootId); + const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true; + const invites = isPublicInvite + ? (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []) + : (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []).filter(inv => { + if (typeof inv === "string") return inv !== code; + return inv.code !== code; + }); + let updated = { + type: "map", + replaces: tipId, + title: oldDecrypted.title || "", + lat: oldDecrypted.lat, + lng: oldDecrypted.lng, + description: oldDecrypted.description || "", + markerLabel: oldDecrypted.markerLabel || "", + mapType: oldDecrypted.mapType, + tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [], + author: oldDecrypted.author, + members: [...(Array.isArray(oldDecrypted.members) ? oldDecrypted.members : []), userId], + invites, + ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}), + ...(oldDecrypted.image ? { image: oldDecrypted.image } : {}), + createdAt: oldDecrypted.createdAt, + updatedAt: new Date().toISOString() + }; + if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated); + else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId); + await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r))); + await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())); + if (tribeCrypto && mapKey) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys"); + const memberKeys = {}; + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {} + if (matched.author && matched.author !== userId) { + try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: tribeCrypto.getGen(rootId) || 1, memberKeys }, () => resolve()); + }); + } + } catch (_) {} + } + return rootId; + }, + + async joinMap(mapId) { + const userId = (await openSsb()).id; + const map = await this.getMapById(mapId, userId); + if (!map) throw new Error("Map not found"); + if (Array.isArray(map.members) && map.members.includes(userId)) return map.rootId || map.key; + if (tribeCrypto && Array.isArray(map.invites)) { + const pub = map.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)); + if (pub) return await this.joinByInvite(pub.code); + } + throw new Error("This map requires an invite code to join"); } }; }; diff --git a/nodejs-project/nodejs-project/src/models/pads_model.js b/nodejs-project/nodejs-project/src/models/pads_model.js index 3219ec38..1803021d 100644 --- a/nodejs-project/nodejs-project/src/models/pads_model.js +++ b/nodejs-project/nodejs-project/src/models/pads_model.js @@ -20,16 +20,41 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => { const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } let keyringPath = null - const getKeyring = () => { + let migratedToTribeCrypto = false + const getLegacyKeyringPath = () => { if (!keyringPath) { const ssbConfig = require("../server/node_modules/ssb-config/inject")() keyringPath = path.join(ssbConfig.path, "pad-keys.json") } - try { return JSON.parse(fs.readFileSync(keyringPath, "utf8")) } catch (e) { return {} } + return keyringPath + } + const migrateLegacyKeyring = () => { + if (migratedToTribeCrypto || !tribeCrypto) { migratedToTribeCrypto = true; return } + migratedToTribeCrypto = true + try { + const p = getLegacyKeyringPath() + if (!fs.existsSync(p)) return + const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {} + for (const [rootId, keyHex] of Object.entries(legacy)) { + if (rootId && keyHex && !tribeCrypto.getKey(rootId)) { + tribeCrypto.setKey(rootId, keyHex, 1) + } + } + } catch (_) {} + } + const getPadKey = (rootId) => { + migrateLegacyKeyring() + if (tribeCrypto) return tribeCrypto.getKey(rootId) || null + try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null } + } + const setPadKey = (rootId, keyHex) => { + migrateLegacyKeyring() + if (tribeCrypto) { tribeCrypto.setKey(rootId, keyHex, 1); return } + let kr = {} + try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {} + kr[rootId] = keyHex + fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8") } - const saveKeyring = (kr) => fs.writeFileSync(keyringPath, JSON.stringify(kr, null, 2), "utf8") - const getPadKey = (rootId) => { const kr = getKeyring(); return kr[rootId] || null } - const setPadKey = (rootId, keyHex) => { const kr = getKeyring(); kr[rootId] = keyHex; saveKeyring(kr) } const encryptField = (text, keyHex) => { const key = Buffer.from(keyHex, "hex") @@ -225,6 +250,13 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => { if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex") const enc = (text) => encryptField(text, keyHex) + const initialInvites = [] + if (validStatus === "OPEN" && !usesTribeKey) { + const pubCode = crypto.randomBytes(INVITE_BYTES).toString("hex") + const ek = encryptForInvite(keyHex, pubCode) + initialInvites.push({ code: pubCode, ek, gen: 1, public: true }) + } + const content = { type: "pad", title: enc(safeText(title)), @@ -233,17 +265,28 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => { tags: enc(normalizeTags(tagsRaw).join(",")), author: ssbClient.id, members: [ssbClient.id], - invites: [], + invites: initialInvites, createdAt: now, updatedAt: now, encrypted: true, ...(tribeId ? { tribeId } : {}) } + const userId = ssbClient.id return new Promise((resolve, reject) => { ssbClient.publish(content, (err, msg) => { if (err) return reject(err) - if (!usesTribeKey) setPadKey(msg.key, keyHex) + if (!usesTribeKey) { + setPadKey(msg.key, keyHex) + if (tribeCrypto) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys) + ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg)) + return + } catch (_) {} + } + } resolve(msg) }) }) @@ -452,13 +495,31 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => { } if (!matchedPad) throw new Error("Invalid or expired invite code") if (matchedPad.members.includes(userId)) throw new Error("Already a member") + let padKey = null + let resolvedRootId = null if (typeof matchedInvite === "object" && matchedInvite.ek) { - const padKey = decryptFromInvite(matchedInvite.ek, code) - const rootId = await this.resolveRootId(matchedPad.rootId) - setPadKey(rootId, padKey) + padKey = decryptFromInvite(matchedInvite.ek, code) + resolvedRootId = await this.resolveRootId(matchedPad.rootId) + setPadKey(resolvedRootId, padKey) } await this.addMemberToPad(matchedPad.rootId, userId) - const invites = matchedPad.invites.filter(inv => { + if (tribeCrypto && padKey && resolvedRootId) { + try { + const ssbKeys = require("../server/node_modules/ssb-keys") + const memberKeys = {} + try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {} + if (matchedPad.author && matchedPad.author !== userId) { + try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {} + } + if (Object.keys(memberKeys).length) { + await new Promise((resolve) => { + ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve()) + }) + } + } catch (_) {} + } + const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true + const invites = isPublicInvite ? matchedPad.invites : matchedPad.invites.filter(inv => { if (typeof inv === "string") return inv !== code return inv.code !== code }) diff --git a/nodejs-project/nodejs-project/src/models/search_model.js b/nodejs-project/nodejs-project/src/models/search_model.js index 80fa71a2..491e7a5b 100644 --- a/nodejs-project/nodejs-project/src/models/search_model.js +++ b/nodejs-project/nodejs-project/src/models/search_model.js @@ -3,13 +3,38 @@ const moment = require('../server/node_modules/moment'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; -module.exports = ({ cooler, padsModel }) => { +module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; + const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']); + + const tryDecryptStandalone = (content) => { + if (!tribeCrypto || !content || !content.encryptedPayload) return null; + const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean); + for (const cand of rootCandidates) { + const keys = tribeCrypto.getKeys(cand); + if (!keys || !keys.length) continue; + try { + const dec = tribeCrypto.decryptContent(content, keys.map(k => [k])); + if (dec && !dec._undecryptable) return dec; + } catch (_) {} + } + return null; + }; + + const tryDecryptTribe = async (content) => { + if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null; + try { + const dec = await tribeCrypto.decryptFromTribe(content, tribesModel); + if (dec && !dec._undecryptable) return dec; + } catch (_) {} + return null; + }; + const searchableTypes = [ 'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed', 'votes', 'report', 'task', 'event', 'bookmark', 'document', @@ -250,6 +275,7 @@ module.exports = ({ cooler, padsModel }) => { const search = async ({ query, types = [], resultsPerPage = "10" }) => { const ssbClient = await openSsb(); + const viewerId = ssbClient.id; const queryLower = String(query || '').toLowerCase(); const messages = await new Promise((res, rej) => { @@ -277,6 +303,39 @@ module.exports = ({ cooler, padsModel }) => { latestByKey.delete(oldId); } + const viewerTribeIds = new Set(); + if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') { + try { + const myTribes = await tribesModel.listTribesForViewer(viewerId); + for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key)); + } catch (_) {} + } + + for (const [k, msg] of Array.from(latestByKey.entries())) { + const c = msg?.value?.content; + if (!c) { latestByKey.delete(k); continue; } + if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) { + latestByKey.delete(k); + continue; + } + if (c.encryptedPayload) { + let dec = null; + if (c.tribeId) dec = await tryDecryptTribe(c); + if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) { + const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : []; + if (keys && keys.length) { + try { + const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk])); + if (out && !out._undecryptable) dec = out; + } catch (_) {} + } + } + if (!dec) dec = tryDecryptStandalone(c); + if (!dec) { latestByKey.delete(k); continue; } + msg.value.content = { ...c, ...dec, encryptedPayload: undefined }; + } + } + if (padsModel) { for (const msg of latestByKey.values()) { const c = msg?.value?.content; diff --git a/nodejs-project/nodejs-project/src/models/stats_model.js b/nodejs-project/nodejs-project/src/models/stats_model.js index 7223bbca..52a2579a 100644 --- a/nodejs-project/nodejs-project/src/models/stats_model.js +++ b/nodejs-project/nodejs-project/src/models/stats_model.js @@ -319,17 +319,17 @@ module.exports = ({ cooler }) => { const allTribesPublic = tribeDedupNodes .filter(n => n.content?.isAnonymous === false) - .map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key })); + .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key })); const allTribes = allTribesPublic.map(t => t.name); const memberTribesDetailed = tribeDedupNodes .filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId)) - .map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key })); + .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key })); const myPrivateTribesDetailed = tribeDedupNodes .filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId)) - .map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key })); + .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key })); const content = {}; const opinions = {}; @@ -446,6 +446,40 @@ module.exports = ({ cooler }) => { .slice(0, 5) .map(([id, count]) => ({ id, count })); + const tagCount = new Map(); + for (const t of types) { + for (const v of Array.from(tipOf[t].values())) { + const tags = v.content && Array.isArray(v.content.tags) ? v.content.tags : []; + for (const tg of tags) { + const key = String(tg || '').trim(); + if (!key) continue; + tagCount.set(key, (tagCount.get(key) || 0) + 1); + } + } + } + const topTags = Array.from(tagCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([tag, count]) => ({ tag, count })); + + const totalTypeCount = types.reduce((s, t) => s + (Array.from(tipOf[t].values()).length || 0), 0); + const topTypeBlacklist = new Set(['shopProduct','chatMessage','padEntry','calendarDate','calendarNote','log']); + const topTypes = types + .filter(t => !topTypeBlacklist.has(t)) + .map(t => ({ type: t, count: Array.from(tipOf[t].values()).length || 0 })) + .filter(o => o.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const myMsgsAll = allMsgs.filter(m => m.value.author === userId); + const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0; + const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0; + const tombstoneRatio = allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0; + + const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b); + const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0; + const networkMsgsPerDay = networkSpanDays > 0 ? (allMsgs.length / networkSpanDays) : 0; + const addrMap = readAddrMap(); const myAddress = addrMap[userId] || null; const banking = { @@ -507,8 +541,19 @@ module.exports = ({ cooler }) => { }, tombstoneKPIs: { networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length, - ratio: allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0 + ratio: tombstoneRatio }, + networkKPIs: { + totalMsgs: allMsgs.length, + myMsgs: myMsgsAll.length, + myShare, + avgMsgsPerInhabitant, + networkSpanDays, + networkMsgsPerDay + }, + topTags, + topTypes, + totalTypeCount, banking }; diff --git a/nodejs-project/nodejs-project/src/models/tags_model.js b/nodejs-project/nodejs-project/src/models/tags_model.js index 10b862ca..fe002f72 100644 --- a/nodejs-project/nodejs-project/src/models/tags_model.js +++ b/nodejs-project/nodejs-project/src/models/tags_model.js @@ -130,23 +130,23 @@ module.exports = ({ cooler, padsModel, tribesModel }) => { for (const oldId of replacesMap.keys()) latestByKey.delete(oldId); - const anonTribeIds = new Set(); + const viewerId = ssbClient.id; + const viewerVisibleTribeIds = new Set(); if (tribesModel) { - const allTribes = await tribesModel.listAll().catch(() => []); - for (const tribe of allTribes) { - if (tribe.isAnonymous === true) anonTribeIds.add(tribe.id); - } + const visibleTribes = await tribesModel.listTribesForViewer(viewerId).catch(() => []); + for (const tribe of visibleTribes) viewerVisibleTribeIds.add(tribe.id); } let filtered = Array.from(latestByKey.values()).filter(msg => { const c = msg?.value?.content; if (!c || c.type === 'tombstone') return false; if (tombstoned.has(msg.key)) return false; + if (c.encryptedPayload) return false; if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false; - if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; + if (c.tribeId && !viewerVisibleTribeIds.has(c.tribeId)) return false; if (c.type === 'event' && c.isPublic === 'private') return false; if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false; - if ((c.type === 'chat' || c.type === 'pad') && c.status === 'INVITE-ONLY') return false; + if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false; if (c.type === 'shop' && c.visibility === 'CLOSED') return false; return true; }); diff --git a/nodejs-project/nodejs-project/src/models/tribe_crypto.js b/nodejs-project/nodejs-project/src/models/tribe_crypto.js index 99e5db3a..06ec591e 100644 --- a/nodejs-project/nodejs-project/src/models/tribe_crypto.js +++ b/nodejs-project/nodejs-project/src/models/tribe_crypto.js @@ -13,10 +13,12 @@ const ENVELOPE_PRESERVE = new Set([ 'type', 'tribeId', 'contentType', 'replaces', 'target', 'author', 'createdAt', 'updatedAt', 'encryptedPayload', 'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId', + 'members', 'invites', 'participants', '_decrypted', '_undecryptable' ]); -const INVITE_SALT = 'SolarNET.HuB'; +const INVITE_SALT_LEGACY = 'SolarNET.HuB'; +const INVITE_SCRYPT = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; module.exports = (configPath) => { const keyringPath = path.join(configPath, 'tribe-keys.json'); @@ -25,6 +27,7 @@ module.exports = (configPath) => { const loadKeyring = () => { try { keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8')); + try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} } catch (e) { if (e.code !== 'ENOENT') throw e; keyring = {}; @@ -34,8 +37,9 @@ module.exports = (configPath) => { const saveKeyring = () => { const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now(); - fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), 'utf8'); + fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 }); fs.renameSync(tmp, keyringPath); + try { fs.chmodSync(keyringPath, 0o600); } catch (_) {} }; const generateTribeKey = () => crypto.randomBytes(32).toString('hex'); @@ -60,8 +64,30 @@ module.exports = (configPath) => { saveKeyring(); }; + const setKeys = (tribeRootId, keysHex, topGen) => { + if (!Array.isArray(keysHex) || !keysHex.length) return; + const seen = new Set(); + const dedup = []; + for (const k of keysHex) { if (k && !seen.has(k)) { seen.add(k); dedup.push(k); } } + keyring[tribeRootId] = { keys: dedup, gen: topGen || dedup.length }; + saveKeyring(); + }; + + const mergeKeys = (tribeRootId, incomingKeys, topGen) => { + const entry = keyring[tribeRootId] || { keys: [], gen: 0 }; + const seen = new Set(entry.keys); + const merged = [...entry.keys]; + for (const k of incomingKeys) { + if (k && !seen.has(k)) { seen.add(k); merged.push(k); } + } + keyring[tribeRootId] = { keys: merged, gen: Math.max(entry.gen || 0, topGen || merged.length) }; + saveKeyring(); + return keyring[tribeRootId].gen; + }; + const addNewKey = (tribeRootId, newKeyHex) => { const entry = keyring[tribeRootId] || { keys: [], gen: 0 }; + if (entry.keys.includes(newKeyHex)) return entry.gen; entry.keys.unshift(newKeyHex); entry.gen = (entry.gen || 0) + 1; keyring[tribeRootId] = entry; @@ -69,65 +95,110 @@ module.exports = (configPath) => { return entry.gen; }; - const encryptWithKey = (plaintext, keyHex) => { + const canonicalAad = (envelope) => { + if (!envelope) return null; + const fields = ['type', 'tribeId', 'contentType', 'replaces', 'author', 'createdAt']; + const obj = {}; + for (const f of fields) if (envelope[f] !== undefined && envelope[f] !== null) obj[f] = envelope[f]; + return Buffer.from(JSON.stringify(obj), 'utf8'); + }; + + const encryptWithKey = (plaintext, keyHex, aad) => { const key = Buffer.from(keyHex, 'hex'); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + if (aad) cipher.setAAD(aad); const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); return iv.toString('hex') + authTag.toString('hex') + enc.toString('hex'); }; - const decryptWithKey = (encrypted, keyHex) => { + const decryptWithKey = (encrypted, keyHex, aad) => { const key = Buffer.from(keyHex, 'hex'); const iv = Buffer.from(encrypted.slice(0, 24), 'hex'); const authTag = Buffer.from(encrypted.slice(24, 56), 'hex'); const ciphertext = Buffer.from(encrypted.slice(56), 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + if (aad) decipher.setAAD(aad); decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); }; - const encryptForInvite = (tribeKeyHex, inviteCode) => { - const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); + const generateInviteSalt = () => crypto.randomBytes(16).toString('hex'); + + const deriveInviteKey = (inviteCode, salt) => { + if (salt === undefined || salt === null || salt === '') { + return crypto.scryptSync(inviteCode, INVITE_SALT_LEGACY, 32); + } + return crypto.scryptSync(inviteCode, salt, 32, INVITE_SCRYPT); + }; + + const hashInviteCode = (inviteCode, salt) => { + const s = salt === undefined || salt === null || salt === '' ? INVITE_SALT_LEGACY : salt; + return crypto.createHmac('sha256', s).update(String(inviteCode), 'utf8').digest('hex'); + }; + + const encryptForInvite = (tribeKeyHex, inviteCode, salt) => { + const derived = deriveInviteKey(inviteCode, salt); return encryptWithKey(tribeKeyHex, derived.toString('hex')); }; - const decryptFromInvite = (encryptedKey, inviteCode) => { - const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); + const decryptFromInvite = (encryptedKey, inviteCode, salt) => { + const derived = deriveInviteKey(inviteCode, salt); return decryptWithKey(encryptedKey, derived.toString('hex')); }; - const encryptChainForInvite = (ancestryRootIds, inviteCode) => { - const chain = ancestryRootIds.map(rootId => ({ rootId, key: getKey(rootId), gen: getGen(rootId) })); - if (chain.some(e => !e.key)) return null; - const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); + const encryptChainForInvite = (ancestryRootIds, inviteCode, salt) => { + const chain = ancestryRootIds.map(rootId => ({ + rootId, + key: getKey(rootId), + keys: getKeys(rootId), + gen: getGen(rootId) + })); + if (chain.some(e => !e.key || !Array.isArray(e.keys) || !e.keys.length)) return null; + const derived = deriveInviteKey(inviteCode, salt); return encryptWithKey(JSON.stringify(chain), derived.toString('hex')); }; - const decryptChainFromInvite = (encryptedPayload, inviteCode) => { - const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); + const decryptChainFromInvite = (encryptedPayload, inviteCode, salt) => { + const derived = deriveInviteKey(inviteCode, salt); try { const json = decryptWithKey(encryptedPayload, derived.toString('hex')); const parsed = JSON.parse(json); - if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && e.key)) return parsed; + if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && (e.key || (Array.isArray(e.keys) && e.keys.length)))) { + return parsed.map(e => ({ + rootId: e.rootId, + key: e.key || (Array.isArray(e.keys) ? e.keys[0] : null), + keys: Array.isArray(e.keys) && e.keys.length ? e.keys : (e.key ? [e.key] : []), + gen: e.gen || 1 + })); + } } catch (_) {} return null; }; - const encryptChain = (plaintext, keyChain) => { + const inviteMatchesCode = (inv, code) => { + if (typeof inv === 'string') return inv === code; + if (!inv || typeof inv !== 'object') return false; + if (inv.codeHash) return inv.codeHash === hashInviteCode(code, inv.salt); + if (inv.code) return inv.code === code; + return false; + }; + + const encryptChain = (plaintext, keyChain, aad) => { let data = plaintext; - for (const keyHex of keyChain) { - data = encryptWithKey(data, keyHex); + const last = keyChain.length - 1; + for (let i = 0; i < keyChain.length; i++) { + data = encryptWithKey(data, keyChain[i], i === last ? aad : undefined); } return data; }; - const decryptChain = (encrypted, keyChain) => { + const decryptChain = (encrypted, keyChain, aad) => { const reversed = [...keyChain].reverse(); let data = encrypted; - for (const keyHex of reversed) { - data = decryptWithKey(data, keyHex); + for (let i = 0; i < reversed.length; i++) { + data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined); } return data; }; @@ -145,20 +216,32 @@ module.exports = (configPath) => { } } const plaintext = JSON.stringify(payload); - const encryptedPayload = encryptChain(plaintext, keyChain); const result = {}; for (const [k, v] of Object.entries(content)) { if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) { result[k] = v; } } + const aad = canonicalAad(result); + const encryptedPayload = encryptChain(plaintext, keyChain, aad); result.encryptedPayload = encryptedPayload; return result; }; const decryptContent = (content, keyChainSets) => { if (!content.encryptedPayload) return content; + const envelope = { ...content }; + delete envelope.encryptedPayload; + const aad = canonicalAad(envelope); for (const keyChain of keyChainSets) { + try { + const plaintext = decryptChain(content.encryptedPayload, keyChain, aad); + const payload = JSON.parse(plaintext); + const result = { ...content }; + delete result.encryptedPayload; + Object.assign(result, payload); + return result; + } catch (e) {} try { const plaintext = decryptChain(content.encryptedPayload, keyChain); const payload = JSON.parse(plaintext); @@ -229,10 +312,31 @@ module.exports = (configPath) => { const decryptFromTribe = async (content, tribesModel) => { if (!content || !content.encryptedPayload) return content; const tid = content.tribeId; - if (!tid) return content; - const sets = await resolveKeyChainSets(tid, tribesModel); - if (!sets || !sets.length) return { ...content, _undecryptable: true }; - return decryptContent(content, sets); + if (tid) { + let sets = null; + try { sets = await resolveKeyChainSets(tid, tribesModel); } catch (_) {} + if (sets && sets.length) { + const r = decryptContent(content, sets); + if (r && !r._undecryptable) return r; + } + const directKeys = getKeys(tid); + if (directKeys && directKeys.length) { + const r = decryptContent(content, directKeys.map(k => [k])); + if (r && !r._undecryptable) return r; + } + } + const candidateRoots = [ + content.calendarId, content.chatId, content.padId, + content.mapId, content.roomId, content.parentId, content.dateId + ].filter(Boolean); + for (const rid of candidateRoots) { + const keys = getKeys(rid); + if (keys && keys.length) { + const r = decryptContent(content, keys.map(k => [k])); + if (r && !r._undecryptable) return r; + } + } + return { ...content, _undecryptable: true }; }; const createHelpers = (tribesModel) => ({ @@ -267,10 +371,11 @@ module.exports = (configPath) => { SENSITIVE_FIELDS, ENVELOPE_PRESERVE, loadKeyring, saveKeyring, - generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey, + generateTribeKey, getKey, getKeys, getGen, setKey, setKeys, mergeKeys, addNewKey, encryptWithKey, decryptWithKey, encryptForInvite, decryptFromInvite, encryptChainForInvite, decryptChainFromInvite, + generateInviteSalt, hashInviteCode, inviteMatchesCode, encryptChain, decryptChain, encryptContent, decryptContent, boxKeyForMember, unboxKeyFromMember, diff --git a/nodejs-project/nodejs-project/src/models/tribes_content_model.js b/nodejs-project/nodejs-project/src/models/tribes_content_model.js index fb96e0c4..17d403e3 100644 --- a/nodejs-project/nodejs-project/src/models/tribes_content_model.js +++ b/nodejs-project/nodejs-project/src/models/tribes_content_model.js @@ -1,6 +1,7 @@ const pull = require('../server/node_modules/pull-stream'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; +const tribeLogLimit = Math.max(logLimit, 100000); const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia']; const categories = require('../backend/opinion_categories'); @@ -30,7 +31,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => { const ssbClient = await openSsb(); return new Promise((resolve, reject) => pull( - ssbClient.createLogStream({ limit: logLimit }), + ssbClient.createLogStream({ limit: tribeLogLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) ) ); diff --git a/nodejs-project/nodejs-project/src/models/tribes_model.js b/nodejs-project/nodejs-project/src/models/tribes_model.js index 5d331a7e..c540fde1 100644 --- a/nodejs-project/nodejs-project/src/models/tribes_model.js +++ b/nodejs-project/nodejs-project/src/models/tribes_model.js @@ -2,6 +2,7 @@ const pull = require('../server/node_modules/pull-stream'); const crypto = require('crypto'); const { getConfig } = require('../configs/config-manager.js'); const logLimit = getConfig().ssbLogStream?.limit || 1000; +const tribeLogLimit = Math.max(logLimit, 100000); const INVITE_CODE_BYTES = 16; const VALID_INVITE_MODES = ['strict', 'open']; @@ -57,7 +58,7 @@ module.exports = ({ cooler, tribeCrypto }) => { const client = await openSsb(); return new Promise((resolve, reject) => { pull( - client.createLogStream({ limit: logLimit }), + client.createLogStream({ limit: tribeLogLimit }), pull.collect((err, msgs) => { if (err) return reject(err); const tombstones = new Map(); @@ -88,6 +89,7 @@ module.exports = ({ cooler, tribeCrypto }) => { let progress = true; while (progress) { progress = false; + const candidatesByReplaces = new Map(); for (const [k, entry] of tribeMsgs.entries()) { if (tribes.has(k)) continue; const replaces = entry.content.replaces; @@ -106,10 +108,25 @@ module.exports = ({ cooler, tribeCrypto }) => { if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue; if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue; } - parent.set(k, replaces); - child.set(replaces, k); - tribes.set(k, entry); - rootByTip.set(k, root); + if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []); + candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root }); + } + for (const [replaces, candidates] of candidatesByReplaces.entries()) { + if (child.has(replaces)) continue; + let winner = candidates[0]; + for (let i = 1; i < candidates.length; i++) { + const c = candidates[i]; + if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; } + if (winner.isRootAuthor && !c.isRootAuthor) continue; + const wt = winner.entry._ts || 0; + const ct = c.entry._ts || 0; + if (ct < wt) winner = c; + else if (ct === wt && c.k < winner.k) winner = c; + } + parent.set(winner.k, replaces); + child.set(replaces, winner.k); + tribes.set(winner.k, winner.entry); + rootByTip.set(winner.k, winner.root); progress = true; } } @@ -129,7 +146,25 @@ module.exports = ({ cooler, tribeCrypto }) => { const tip = tipOf(root); tipByRoot.set(root, tip); } - tribeIndex = { tribes, tombstoned, parent, child, tipByRoot, rootByTip }; + const effectivelyTombstoned = new Set(tombstoned); + let cascadeProgress = true; + while (cascadeProgress) { + cascadeProgress = false; + for (const k of tribes.keys()) { + if (effectivelyTombstoned.has(k)) continue; + const root = rootOf(k); + if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascadeProgress = true; continue; } + const entry = tribes.get(k); + const pid = entry?.content?.parentTribeId; + if (!pid) continue; + const parentRoot = rootOf(pid); + if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) { + effectivelyTombstoned.add(k); + cascadeProgress = true; + } + } + } + tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip }; tribeIndexTs = Date.now(); resolve(tribeIndex); }) @@ -196,8 +231,16 @@ module.exports = ({ cooler, tribeCrypto }) => { if (tribeCrypto) { const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null); if (Array.isArray(ancestryIds) && ancestryIds.length) { - const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code); - if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(ancestryIds[0]) }; + const salt = tribeCrypto.generateInviteSalt(); + const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code, salt); + if (ekChain) { + invite = { + codeHash: tribeCrypto.hashInviteCode(code, salt), + ekChain, + salt, + gen: tribeCrypto.getGen(ancestryIds[0]) + }; + } } } const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite]; @@ -236,10 +279,7 @@ module.exports = ({ cooler, tribeCrypto }) => { for (const t of tribes) { if (!t.invites) continue; for (const inv of t.invites) { - if (typeof inv === 'string' && inv === code) { - matchedTribe = t; matchedInvite = inv; break; - } - if (typeof inv === 'object' && inv.code === code) { + if (tribeCrypto ? tribeCrypto.inviteMatchesCode(inv, code) : (inv === code || (inv && inv.code === code))) { matchedTribe = t; matchedInvite = inv; break; } } @@ -253,16 +293,23 @@ module.exports = ({ cooler, tribeCrypto }) => { let storedGen = 1; let storedRootId = null; if (tribeCrypto && typeof matchedInvite === 'object') { + const salt = matchedInvite.salt; if (matchedInvite.ekChain) { - const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code); + const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, salt); if (Array.isArray(chain) && chain.length) { - for (const entry of chain) tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1); + for (const entry of chain) { + if (Array.isArray(entry.keys) && entry.keys.length) { + tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length); + } else if (entry.key) { + tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1); + } + } storedRootId = chain[0].rootId; storedTribeKey = chain[0].key; storedGen = chain[0].gen || 1; } } else if (matchedInvite.ek) { - storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code); + storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, salt); storedRootId = await this.getRootId(matchedTribe.id); storedGen = matchedInvite.gen || 1; tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen); @@ -270,8 +317,9 @@ module.exports = ({ cooler, tribeCrypto }) => { } const members = [...matchedTribe.members, userId]; const invites = matchedTribe.invites.filter(inv => { + if (tribeCrypto) return !tribeCrypto.inviteMatchesCode(inv, code); if (typeof inv === 'string') return inv !== code; - return inv.code !== code; + return inv && inv.code !== code; }); const inviteLog = Array.isArray(matchedTribe.inviteLog) ? matchedTribe.inviteLog.map(entry => entry.code === code ? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() } : entry @@ -318,14 +366,18 @@ module.exports = ({ cooler, tribeCrypto }) => { const rootId = await this.getRootId(tribeId); const currentKey = tribeCrypto.getKey(rootId); if (!currentKey) return; + const allKeys = tribeCrypto.getKeys(rootId); const gen = tribeCrypto.getGen(rootId); + const payload = JSON.stringify({ keys: allKeys, gen }); const memberKeys = {}; + const memberKeysFull = {}; for (const memberId of toMembers) { try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {} + try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(payload, memberId, ssbKeys); } catch (_) {} } - if (!Object.keys(memberKeys).length) return; + if (!Object.keys(memberKeys).length && !Object.keys(memberKeysFull).length) return; await new Promise((resolve, reject) => { - ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res)); + ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys, memberKeysFull }, (err, res) => err ? reject(err) : resolve(res)); }); await this.ensureFollowTribeMembers(tribeId).catch(() => {}); }, @@ -341,7 +393,7 @@ module.exports = ({ cooler, tribeCrypto }) => { if (!currentKey) return; const gen = tribeCrypto.getGen(rootId); const msgs = await new Promise((resolve, reject) => { - pull(ssb.createLogStream({ limit: logLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m))); + pull(ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m))); }); const distributed = new Set(); for (const m of msgs) { @@ -386,10 +438,10 @@ module.exports = ({ cooler, tribeCrypto }) => { }, async getTribeById(tribeId) { - const { tribes, tombstoned, child } = await buildTribeIndex(); + const { tribes, tombstoned, effectivelyTombstoned, child } = await buildTribeIndex(); let latestId = tribeId; while (child.has(latestId)) latestId = child.get(latestId); - if (tombstoned.has(latestId)) throw new Error('Tribe not found'); + if (tombstoned.has(latestId) || effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found'); const tribe = tribes.get(latestId); if (!tribe) throw new Error('Tribe not found'); return { @@ -403,6 +455,7 @@ module.exports = ({ cooler, tribeCrypto }) => { isAnonymous: tribe.content.isAnonymous, members: Array.isArray(tribe.content.members) ? tribe.content.members : [], invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [], + inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [], inviteMode: tribe.content.inviteMode || 'strict', status: tribe.content.status || 'OPEN', parentTribeId: tribe.content.parentTribeId || null, @@ -410,12 +463,11 @@ module.exports = ({ cooler, tribeCrypto }) => { createdAt: tribe.content.createdAt, updatedAt: tribe.content.updatedAt, author: tribe.content.author, - inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [], }; }, async listAll() { - const { tribes, tombstoned, tipByRoot, rootByTip } = await buildTribeIndex(); + const { tribes, tombstoned, effectivelyTombstoned, tipByRoot, rootByTip } = await buildTribeIndex(); const resolveParent = (pid) => { if (!pid) return null; const root = rootByTip.get(pid) || pid; @@ -424,6 +476,7 @@ module.exports = ({ cooler, tribeCrypto }) => { const items = []; for (const [root, tip] of tipByRoot) { if (tombstoned.has(root) || tombstoned.has(tip)) continue; + if (effectivelyTombstoned.has(root) || effectivelyTombstoned.has(tip)) continue; const entry = tribes.get(tip); if (!entry) continue; const c = entry.content; @@ -438,6 +491,7 @@ module.exports = ({ cooler, tribeCrypto }) => { isAnonymous: c.isAnonymous !== false, members: Array.isArray(c.members) ? c.members : [], invites: Array.isArray(c.invites) ? c.invites : [], + inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [], inviteMode: c.inviteMode || 'strict', status: c.status || 'OPEN', parentTribeId: resolveParent(c.parentTribeId), @@ -445,7 +499,6 @@ module.exports = ({ cooler, tribeCrypto }) => { createdAt: c.createdAt, updatedAt: c.updatedAt, author: c.author, - inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [], _ts: entry._ts }); } @@ -495,31 +548,38 @@ module.exports = ({ cooler, tribeCrypto }) => { if (!oldKey) return; const newKey = tribeCrypto.generateTribeKey(); const newGen = tribeCrypto.addNewKey(rootId, newKey); + const allKeys = tribeCrypto.getKeys(rootId); + const fullPayload = JSON.stringify({ keys: allKeys, gen: newGen }); const memberKeys = {}; + const memberKeysFull = {}; for (const memberId of remainingMembers) { - memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); + try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); } catch (_) {} + try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(fullPayload, memberId, ssbKeys); } catch (_) {} } const entries = Object.entries(memberKeys); const BATCH_SIZE = 20; for (let i = 0; i < entries.length; i += BATCH_SIZE) { - const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE)); + const batchSingle = Object.fromEntries(entries.slice(i, i + BATCH_SIZE)); + const batchFull = {}; + for (const id of Object.keys(batchSingle)) { + if (memberKeysFull[id]) batchFull[id] = memberKeysFull[id]; + } await new Promise((resolve, reject) => { - ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch }, + ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batchSingle, memberKeysFull: batchFull }, (err, res) => err ? reject(err) : resolve(res)); }); } const tribe = await this.getTribeById(tribeId); if (Array.isArray(tribe.invites) && tribe.invites.length > 0) { - const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]); - const updatedInvites = tribe.invites.map(inv => { - if (typeof inv === 'object' && inv.code) { - const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code); - if (ekChain) return { code: inv.code, ekChain, gen: newGen }; - return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen }; - } - return inv; + const survivingInvites = tribe.invites.map(inv => { + if (typeof inv === 'string') return inv; + if (!inv || typeof inv !== 'object') return inv; + const next = { ...inv, gen: newGen }; + delete next.ekChain; + delete next.ek; + return next; }); - await this.updateTribeInvites(tribeId, updatedInvites); + await this.updateTribeInvites(tribeId, survivingInvites); } }, @@ -530,20 +590,38 @@ module.exports = ({ cooler, tribeCrypto }) => { const config = require('../server/ssb_config'); const msgs = await new Promise((resolve, reject) => { pull( - ssb.createLogStream({ limit: logLimit }), + ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) ); }); + const byTribe = new Map(); for (const m of msgs) { const c = m.value?.content; - if (!c || c.type !== 'tribe-keys') continue; - const myEntry = c.memberKeys && c.memberKeys[ssb.id]; - if (!myEntry) continue; - const currentGen = tribeCrypto.getGen(c.tribeId); - if (c.generation <= currentGen) continue; - const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys); - if (newKey) { - tribeCrypto.addNewKey(c.tribeId, newKey); + if (!c || c.type !== 'tribe-keys' || !c.tribeId) continue; + const fullEntry = c.memberKeysFull && c.memberKeysFull[ssb.id]; + const singleEntry = c.memberKeys && c.memberKeys[ssb.id]; + if (!fullEntry && !singleEntry) continue; + const list = byTribe.get(c.tribeId) || []; + list.push({ generation: c.generation || 0, fullEntry, singleEntry }); + byTribe.set(c.tribeId, list); + } + for (const [tribeId, entries] of byTribe.entries()) { + entries.sort((a, b) => b.generation - a.generation); + const top = entries[0]; + const knownGen = tribeCrypto.getGen(tribeId); + if (top.fullEntry) { + try { + const text = tribeCrypto.unboxKeyFromMember(top.fullEntry, config.keys, ssbKeys); + const parsed = text ? JSON.parse(text) : null; + if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) { + tribeCrypto.mergeKeys(tribeId, parsed.keys, parsed.gen || top.generation || knownGen); + continue; + } + } catch (_) {} + } + if (top.singleEntry && top.generation > knownGen) { + const newKey = tribeCrypto.unboxKeyFromMember(top.singleEntry, config.keys, ssbKeys); + if (newKey) tribeCrypto.addNewKey(tribeId, newKey); } } }, @@ -562,7 +640,7 @@ module.exports = ({ cooler, tribeCrypto }) => { const myFollows = new Map(); await new Promise((resolve, reject) => { pull( - ssb.createLogStream({ limit: logLimit }), + ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, msgs) => { if (err) return reject(err); for (const m of msgs) { @@ -636,12 +714,19 @@ module.exports = ({ cooler, tribeCrypto }) => { tribeIndex = null; }, - async listSubTribes(parentId) { + async listSubTribes(parentId, userId) { const idx = await buildTribeIndex(); const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; }; const parentRoot = rootOf(parentId); const all = await this.listAll(); - return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot); + const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot); + if (!userId) return subs; + const out = []; + for (const sub of subs) { + const ok = await this.canAccessTribe(userId, sub.id).catch(() => false); + if (ok) out.push(sub); + } + return out; }, async isTribeMember(userId, tribeId) { @@ -661,6 +746,10 @@ module.exports = ({ cooler, tribeCrypto }) => { try { const tribe = await this.getTribeById(tribeId); if (!tribe) return false; + if (tribe.parentTribeId) { + const parentOk = await this.canAccessTribe(userId, tribe.parentTribeId).catch(() => false); + if (!parentOk) return false; + } if (tribe.author === userId) return true; if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true; const effective = await this.getEffectiveStatus(tribeId); diff --git a/nodejs-project/nodejs-project/src/server/SSB_server.js b/nodejs-project/nodejs-project/src/server/SSB_server.js index ae672907..8c1805f1 100644 --- a/nodejs-project/nodejs-project/src/server/SSB_server.js +++ b/nodejs-project/nodejs-project/src/server/SSB_server.js @@ -48,8 +48,31 @@ const manifestFile = path.join(config.path, 'manifest.json'); let server; const argv = process.argv.slice(2); +const isLockError = (err) => { + if (!err) return false; + if (err.name === 'OpenError') return true; + const msg = String(err.message || ''); + return /Resource temporarily unavailable/i.test(msg) && /\.ssb\/.*LOCK/i.test(msg); +}; + +const handleFatal = (err) => { + if (isLockError(err)) { + console.log(''); + console.log('Another Oasis instance is already running on this device. Close the other instance (or kill the process) and try again.'); + console.log(''); + process.exit(1); + } + throw err; +}; + +process.on('uncaughtException', handleFatal); + if (argv[0] === 'start') { - server = Server(config); + try { + server = Server(config); + } catch (err) { + handleFatal(err); + } fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2)); const { cmdAliases } = require('../client/cli-cmd-aliases'); @@ -91,7 +114,14 @@ if (argv[0] === 'start') { } const { printMetadata, colors } = require('./ssb_metadata'); - printMetadata('OASIS Server Only', colors.cyan); + printMetadata('OASIS Server Only', colors.cyan, null); + + setTimeout(async () => { + try { + const bankingModel = require('../models/banking_model.js')({}); + await bankingModel.ensureSelfAddressPublished(); + } catch (_) {} + }, 5000); } diff --git a/nodejs-project/nodejs-project/src/server/package.json b/nodejs-project/nodejs-project/src/server/package.json index df1f8a68..6645845e 100644 --- a/nodejs-project/nodejs-project/src/server/package.json +++ b/nodejs-project/nodejs-project/src/server/package.json @@ -1,6 +1,6 @@ { "name": "@krakenslab/oasis", - "version": "0.7.5", + "version": "0.7.6", "description": "Oasis Social Networking Project Utopia", "repository": { "type": "git", diff --git a/nodejs-project/nodejs-project/src/views/audio_view.js b/nodejs-project/nodejs-project/src/views/audio_view.js index 150b69b4..465e3b07 100644 --- a/nodejs-project/nodejs-project/src/views/audio_view.js +++ b/nodejs-project/nodejs-project/src/views/audio_view.js @@ -16,7 +16,7 @@ const { option } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl") @@ -100,7 +100,10 @@ const renderAudioOwnerActions = (filter, audioObj, params = {}) => { }; const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => { - const list = safeArr(comments); + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); const commentsCount = list.length; return div( @@ -221,7 +224,7 @@ const renderAudioList = (audios, filter, params = {}) => { return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`), + userLink(audioObj.author), showUpdated ? span( { class: "votations-comment-date" }, @@ -416,7 +419,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`), + userLink(audioObj.author), showUpdated ? span( { class: "votations-comment-date" }, diff --git a/nodejs-project/nodejs-project/src/views/bookmark_view.js b/nodejs-project/nodejs-project/src/views/bookmark_view.js index 0a3cc32b..a082e2f9 100644 --- a/nodejs-project/nodejs-project/src/views/bookmark_view.js +++ b/nodejs-project/nodejs-project/src/views/bookmark_view.js @@ -1,7 +1,7 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); @@ -59,7 +59,10 @@ const renderBookmarkActions = (filter, bookmark, params = {}) => { }; const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => { - const list = safeArr(comments); + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); const commentsCount = list.length; return div( @@ -220,7 +223,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => { return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`), + userLink(bookmark.author), showUpdated ? span( { class: "votations-comment-date" }, @@ -453,7 +456,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`), + userLink(bookmark.author), showUpdated ? span( { class: "votations-comment-date" }, diff --git a/nodejs-project/nodejs-project/src/views/chats_view.js b/nodejs-project/nodejs-project/src/views/chats_view.js index e39f3285..b9691165 100644 --- a/nodejs-project/nodejs-project/src/views/chats_view.js +++ b/nodejs-project/nodejs-project/src/views/chats_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderUrl } = require("../backend/renderUrl") @@ -131,9 +131,7 @@ const renderMessage = (msg, chatAuthor) => { const isSelf = String(msg.author) === String(userId) const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm") const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?" - const authorLink = msg.author - ? a({ href: `/author/${encodeURIComponent(msg.author)}`, class: "user-link" }, shortId) - : span("?") + const authorLink = msg.author ? userLink(msg.author) : span("?") const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null @@ -237,7 +235,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => { ), isRestrictedInviteOnly ? null : tr( td({ class: "tribe-info-value", colspan: "4" }, - a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author) + userLink(chat.author) ) ), tr( @@ -328,9 +326,12 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => { ) : null, div({ class: "chat-messages-list" }, - msgList.length - ? msgList.map(msg => renderMessage(msg, chat.author)) - : p({ class: "chat-no-messages" }, i18n.chatNoMessages) + (() => { + const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image) + return visible.length + ? visible.map(msg => renderMessage(msg, chat.author)) + : p({ class: "chat-no-messages" }, i18n.chatNoMessages) + })() ) ) diff --git a/nodejs-project/nodejs-project/src/views/courts_view.js b/nodejs-project/nodejs-project/src/views/courts_view.js index 71f3724b..6dc0a46e 100644 --- a/nodejs-project/nodejs-project/src/views/courts_view.js +++ b/nodejs-project/nodejs-project/src/views/courts_view.js @@ -1,6 +1,6 @@ const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); const moment = require('../server/node_modules/moment'); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink } = require('./main_views'); const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss'); @@ -341,21 +341,8 @@ const shortId = (id) => { return `${s.slice(0, 6)}…${s.slice(-4)}`; }; -const UserLinkCompact = (id) => - id - ? a( - { class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, - shortId(id) - ) - : span(''); - -const UserLinkFull = (id) => - id - ? a( - { class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, - id - ) - : span(''); +const UserLinkCompact = (id) => id ? userLink(id) : span(''); +const UserLinkFull = (id) => id ? userLink(id) : span(''); const renderRichTextNodes = (raw) => { const text = String(raw || ''); @@ -438,20 +425,14 @@ const CaseCard = (c) => { const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => span( { class: 'mediator' }, - a( - { class: 'user-link', href: `/author/${encodeURIComponent(mId)}` }, - shortId(mId) - ), + userLink(mId), idx < mediatorsAccuser.length - 1 ? span(', ') : null ) ); const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => span( { class: 'mediator' }, - a( - { class: 'user-link', href: `/author/${encodeURIComponent(mId)}` }, - shortId(mId) - ), + userLink(mId), idx < mediatorsRespondent.length - 1 ? span(', ') : null ) ); @@ -637,10 +618,7 @@ const MyCaseCard = (c) => { const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => span( {}, - a( - { class: 'user-link', href: `/author/${encodeURIComponent(mId)}` }, - mId - ), + userLink(mId), idx < mediatorsAccuser.length - 1 ? span(', ') : null ) ); @@ -648,10 +626,7 @@ const MyCaseCard = (c) => { const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => span( {}, - a( - { class: 'user-link', href: `/author/${encodeURIComponent(mId)}` }, - mId - ), + userLink(mId), idx < mediatorsRespondent.length - 1 ? span(', ') : null ) ); @@ -945,12 +920,7 @@ const NominationsTable = (nominations = [], currentUserId = '') => { currentUserId && String(n.judgeId || '') === String(currentUserId || ''); return tr( - td( - a( - { class: 'user-link', href: `/author/${encodeURIComponent(n.judgeId)}` }, - n.judgeId - ) - ), + td(userLink(n.judgeId)), td(String(n.supports || 0)), td(fmt(n.createdAt)), td( @@ -1081,20 +1051,14 @@ const CaseDetailsBlock = (c) => { const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => span( { class: 'mediator' }, - a( - { class: 'user-link', href: `/author/${encodeURIComponent(mId)}` }, - shortId(mId) - ), + userLink(mId), idx < mediatorsAccuser.length - 1 ? span(', ') : null ) ); const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => span( { class: 'mediator' }, - a( - { class: 'user-link', href: `/author/${encodeURIComponent(mId)}` }, - shortId(mId) - ), + userLink(mId), idx < mediatorsRespondent.length - 1 ? span(', ') : null ) ); diff --git a/nodejs-project/nodejs-project/src/views/cv_view.js b/nodejs-project/nodejs-project/src/views/cv_view.js index 1172867c..f3d84b78 100644 --- a/nodejs-project/nodejs-project/src/views/cv_view.js +++ b/nodejs-project/nodejs-project/src/views/cv_view.js @@ -1,5 +1,5 @@ const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink} = require('./main_views'); const { renderUrl } = require('../backend/renderUrl'); const generateCVBox = (label, content, className) => { @@ -156,7 +156,7 @@ exports.cvView = async (cv) => { }) : null, cv.name ? h2(`${cv.name}`) : null, - cv.contact ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(cv.contact)}` }, cv.contact)) : null, + cv.contact ? p(userLink(cv.contact)) : null, cv.description ? p(...renderUrl(`${cv.description}`)) : null, (cv.personalSkills && cv.personalSkills.length) ? div( diff --git a/nodejs-project/nodejs-project/src/views/document_view.js b/nodejs-project/nodejs-project/src/views/document_view.js index 0c828018..c495f4bb 100644 --- a/nodejs-project/nodejs-project/src/views/document_view.js +++ b/nodejs-project/nodejs-project/src/views/document_view.js @@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, span, textarea, require("../server/node_modules/hyperaxe"); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); const opinionCategories = require("../backend/opinion_categories"); @@ -74,7 +74,10 @@ const renderDocumentActions = (filter, doc, params = {}) => { }; const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => { - const list = safeArr(comments); + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); const commentsCount = list.length; return div( @@ -203,7 +206,7 @@ const renderDocumentList = (documents, filter, params = {}) => { return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`), + userLink(doc.author), showUpdated ? span( { class: "votations-comment-date" }, @@ -408,7 +411,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params = return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`), + userLink(doc.author), showUpdated ? span( { class: "votations-comment-date" }, diff --git a/nodejs-project/nodejs-project/src/views/event_view.js b/nodejs-project/nodejs-project/src/views/event_view.js index 92eabacb..3c93ddc3 100644 --- a/nodejs-project/nodejs-project/src/views/event_view.js +++ b/nodejs-project/nodejs-project/src/views/event_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); @@ -163,10 +163,15 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) ) ), - comments && comments.length + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + return visibleComments.length ? div( { class: "comments-list" }, - comments.map((c) => { + visibleComments.map((c) => { const author = c.value && c.value.author ? c.value.author : ""; const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; @@ -192,7 +197,8 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all ); }) ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); + })() ); }; @@ -235,7 +241,7 @@ const renderEventItem = (e, filter) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(e.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: "user-link" }, `${e.organizer}`) + userLink(e.organizer) ) ); }; @@ -472,7 +478,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => { attendees.length ? attendees .filter(Boolean) - .map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]) + .map((id, i) => [i > 0 ? ", " : "", userLink(id)]) .flat() : i18n.noAttendees ) @@ -481,7 +487,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: "user-link" }, `${event.organizer}`) + userLink(event.organizer) ) ), renderEventCommentsSection(event.id, comments, currentFilter) diff --git a/nodejs-project/nodejs-project/src/views/favorites_view.js b/nodejs-project/nodejs-project/src/views/favorites_view.js index 72eea86b..342cbc8c 100644 --- a/nodejs-project/nodejs-project/src/views/favorites_view.js +++ b/nodejs-project/nodejs-project/src/views/favorites_view.js @@ -1,6 +1,6 @@ const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const moment = require("../server/node_modules/moment"); const { renderUrl } = require("../backend/renderUrl"); @@ -90,7 +90,7 @@ const renderFavoriteCard = (item, filter) => { p( { class: "card-footer" }, absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "", - item.author ? a({ href: `/author/${encodeURIComponent(item.author)}`, class: "user-link" }, `${item.author}`) : "" + item.author ? userLink(item.author) : "" ) ); }; diff --git a/nodejs-project/nodejs-project/src/views/feed_view.js b/nodejs-project/nodejs-project/src/views/feed_view.js index 4c36810c..3482ee05 100644 --- a/nodejs-project/nodejs-project/src/views/feed_view.js +++ b/nodejs-project/nodejs-project/src/views/feed_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink } = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderTextWithStyles } = require("../backend/renderTextWithStyles"); const opinionCategories = require("../backend/opinion_categories"); @@ -193,7 +193,7 @@ const renderFeedCard = (feed) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${createdAt} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`), + userLink(authorId), content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null ) ) @@ -361,7 +361,7 @@ exports.singleFeedView = (feed, comments = []) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${createdAt} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, authorId), + userLink(authorId), content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null ) ) diff --git a/nodejs-project/nodejs-project/src/views/forum_view.js b/nodejs-project/nodejs-project/src/views/forum_view.js index 20614ab9..a5db937b 100644 --- a/nodejs-project/nodejs-project/src/views/forum_view.js +++ b/nodejs-project/nodejs-project/src/views/forum_view.js @@ -3,7 +3,7 @@ const { input, label, br, select, option, h2, textarea } = require("../server/node_modules/hyperaxe"); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink } = require('./main_views'); const { config } = require('../server/SSB_server.js'); const { renderUrl } = require('../backend/renderUrl'); const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); @@ -100,6 +100,7 @@ const renderForumForm = () => const renderThread = (nodes, level = 0, forumId) => { if (!Array.isArray(nodes)) return []; return [...nodes] + .filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length)) .sort((a, b) => wilsonScore(b.positiveVotes, b.negativeVotes) - wilsonScore(a.positiveVotes, a.negativeVotes) @@ -117,11 +118,7 @@ const renderThread = (nodes, level = 0, forumId) => { div({ class: 'comment-header' }, span({ class: 'date-link' }, `${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), - a({ - href: `/author/${encodeURIComponent(m.author)}`, - class: 'user-link', - style: 'margin-left:12px;' - }, m.author), + userLink(m.author), div({ class: 'comment-votes' }, span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`), span({ class: 'votes-count', style: 'margin-left:12px;' }, @@ -164,10 +161,13 @@ const renderThread = (nodes, level = 0, forumId) => { }); }; -const renderForumList = (forums, currentFilter) => - div({ class: 'forum-list' }, - Array.isArray(forums) && forums.length - ? forums.map(f => +const renderForumList = (forums, currentFilter) => { + const visibleForums = (Array.isArray(forums) ? forums : []).filter(f => + (f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim()) + ) + return div({ class: 'forum-list' }, + visibleForums.length + ? visibleForums.map(f => div({ class: 'forum-card' }, div({ class: 'forum-score-col' }, renderVotes(f.key, f.score, f.key) @@ -203,11 +203,7 @@ const renderForumList = (forums, currentFilter) => div({ class: 'forum-footer' }, span({ class: 'date-link' }, `${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), - a({ - href: `/author/${encodeURIComponent(f.author)}`, - class: 'user-link', - style: 'margin-left:12px;' - }, f.author) + userLink(f.author) ), currentFilter === 'mine' && f.author === userId ? div({ class: 'forum-owner-actions' }, @@ -226,6 +222,7 @@ const renderForumList = (forums, currentFilter) => ) : p(i18n.noForums) ); +} exports.forumView = async (forums, currentFilter) => { const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); @@ -312,11 +309,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => { div({ class: 'forum-footer' }, span({ class: 'date-link' }, `${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), - a({ - href: `/author/${encodeURIComponent(forum.author)}`, - class: 'user-link', - style: 'margin-left:12px;' - }, forum.author) + userLink(forum.author) ), div({ class: 'forum-body', diff --git a/nodejs-project/nodejs-project/src/views/games_view.js b/nodejs-project/nodejs-project/src/views/games_view.js index 839305c3..5619f826 100644 --- a/nodejs-project/nodejs-project/src/views/games_view.js +++ b/nodejs-project/nodejs-project/src/views/games_view.js @@ -1,5 +1,5 @@ const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink} = require('./main_views'); const moment = require("../server/node_modules/moment"); const getGames = () => [ @@ -44,7 +44,7 @@ const renderHallOfFame = (hall) => { ...hall[game.id].map((entry, idx) => tr( td(String(idx + 1)), - td(a({ href: `/author/${encodeURIComponent(entry.author)}`, class: 'user-link' }, entry.author)), + td(userLink(entry.author)), td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)), td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014') ) @@ -124,7 +124,7 @@ exports.gamesView = (filter = 'all', hall = null) => { p({ class: 'game-card-desc game-desc-yellow' }, game.desc()), topScore ? p({ class: 'game-top-score' }, - a({ href: `/author/${encodeURIComponent(topScore.author)}`, class: 'user-link' }, topScore.author), + userLink(topScore.author), span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '), String(topScore.score) ) diff --git a/nodejs-project/nodejs-project/src/views/graphos_view.js b/nodejs-project/nodejs-project/src/views/graphos_view.js new file mode 100644 index 00000000..23738e3c --- /dev/null +++ b/nodejs-project/nodejs-project/src/views/graphos_view.js @@ -0,0 +1,129 @@ +const h = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, input, span } = h; +const { template, i18n } = require('./main_views'); + +const TAU = Math.PI * 2; + +const escAttr = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const escText = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const adaptiveLayout = (n) => { + if (n <= 1) return { rRatio: 0.22, nodeR: 30, labelGap: 18 }; + if (n <= 3) return { rRatio: 0.28, nodeR: 26, labelGap: 16 }; + if (n <= 6) return { rRatio: 0.34, nodeR: 22, labelGap: 14 }; + if (n <= 12) return { rRatio: 0.40, nodeR: 18, labelGap: 14 }; + return { rRatio: 0.44, nodeR: 14, labelGap: 12 }; +}; + +const buildGraphSvg = (me, peers) => { + const W = 900; + const H = 600; + const cx = W / 2; + const cy = H / 2; + const N = Math.max(1, peers.length); + const { rRatio, nodeR, labelGap } = adaptiveLayout(N); + const r = Math.min(W, H) * rRatio; + const meR = nodeR + 6; + + const positions = peers.map((peer, i) => { + const angle = (i / N) * TAU - Math.PI / 2; + return { + peer, + x: cx + r * Math.cos(angle), + y: cy + r * Math.sin(angle) + }; + }); + + const edges = positions.map(({ peer, x, y }) => + `` + ).join(''); + + const nodes = positions.map(({ peer, x, y }) => { + const xs = x.toFixed(2); + const ys = y.toFixed(2); + const labelY = (y + nodeR + labelGap).toFixed(2); + const href = `/author/${escAttr(encodeURIComponent(peer.key))}`; + const name = escText(peer.name); + return `` + + `` + + `${name} (${peer.kind})` + + `` + + `${name}` + + ``; + }).join(''); + + const meLabelY = (cy + meR + labelGap + 2).toFixed(2); + const meHref = `/author/${escAttr(encodeURIComponent(me.key))}`; + const meName = escText(me.name); + const center = `` + + `` + + `${meName} (you)` + + `` + + `${meName}` + + ``; + + return `` + + edges + nodes + center + + ``; +}; + +const kpi = (label, value) => div({ class: 'stats-kpi' }, + div({ class: 'stats-kpi-label' }, label), + div({ class: 'stats-kpi-value' }, String(value)) +); + +const legendItem = (kind, label) => + span({ class: 'graphos-legend-item' }, + span({ class: `graphos-legend-dot graphos-node-circle-${kind}` }), + span(label) + ); + +exports.graphosView = ({ filter, me, peers, kpis }) => { + const title = i18n.graphos || 'Graphos'; + const description = i18n.graphosDescription || 'Interactive map of the network around you.'; + const modes = ['ALL', 'MINE']; + + return template( + title, + section( + div({ class: 'tags-header' }, + h2(title), + p(description) + ), + div({ class: 'mode-buttons stats-mode-row' }, + modes.map(m => + form({ method: 'GET', action: '/graphos' }, + input({ type: 'hidden', name: 'filter', value: m }), + button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button']) + ) + ) + ), + div({ class: 'graphos-legend' }, + legendItem('me', i18n.graphosYou || 'You'), + legendItem('online', i18n.online || 'Online'), + filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null, + filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null + ), + div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }), + div({ class: 'stats-block' }, + div({ class: 'stats-grid' }, + kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total), + kpi(i18n.online || 'Online', kpis.online), + filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null, + filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null + ) + ) + ) + ); +}; diff --git a/nodejs-project/nodejs-project/src/views/image_view.js b/nodejs-project/nodejs-project/src/views/image_view.js index 197f68c0..2ea64920 100644 --- a/nodejs-project/nodejs-project/src/views/image_view.js +++ b/nodejs-project/nodejs-project/src/views/image_view.js @@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, img, span, texta require("../server/node_modules/hyperaxe"); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl") const { renderMapLocationVisitLabel } = require("./maps_view"); @@ -160,7 +160,7 @@ const renderImageList = (images, filter, params = {}) => { return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(imgObj.author)}`, class: "user-link" }, `${imgObj.author}`), + userLink(imgObj.author), showUpdated ? span( { class: "votations-comment-date" }, @@ -255,7 +255,10 @@ const renderLightbox = (images) => }); const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => { - const list = safeArr(comments); + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); const commentsCount = list.length; return div( @@ -475,7 +478,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(imageObj.author)}`, class: "user-link" }, `${imageObj.author}`), + userLink(imageObj.author), showUpdated ? span( { class: "votations-comment-date" }, diff --git a/nodejs-project/nodejs-project/src/views/jobs_view.js b/nodejs-project/nodejs-project/src/views/jobs_view.js index 14bddcc2..6ec941a7 100644 --- a/nodejs-project/nodejs-project/src/views/jobs_view.js +++ b/nodejs-project/nodejs-project/src/views/jobs_view.js @@ -1,5 +1,5 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderUrl } = require("../backend/renderUrl") @@ -268,7 +268,7 @@ const renderJobList = (jobs, filter, params = {}) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author), + userLink(job.author), renderUpdatedLabel(job.createdAt, job.updatedAt) ) ) @@ -388,7 +388,7 @@ const renderCVList = (inhabitants) => div( { class: "inhabitant-details" }, user.description ? p(...renderUrl(user.description)) : null, - p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)), + p(userLink(user.id)), div( { class: "cv-actions" }, form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)), @@ -481,7 +481,10 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => { } const renderJobCommentsSection = (jobId, returnTo, comments = []) => { - const list = safeArr(comments) + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text + return t && String(t).trim() + }) const commentsCount = list.length return div( @@ -582,7 +585,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author), + userLink(job.author), renderUpdatedLabel(job.createdAt, job.updatedAt) ) ), diff --git a/nodejs-project/nodejs-project/src/views/main_views.js b/nodejs-project/nodejs-project/src/views/main_views.js index 2c62d845..398c4bbd 100644 --- a/nodejs-project/nodejs-project/src/views/main_views.js +++ b/nodejs-project/nodejs-project/src/views/main_views.js @@ -28,6 +28,23 @@ const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3 const lodash = require("../server/node_modules/lodash"); const markdown = require("./markdown"); const { sanitizeHtml } = require('../backend/sanitizeHtml'); +const nameCache = require('../backend/nameCache'); + +const userLinkLabel = (feedId, knownName) => { + const id = String(feedId || ''); + if (!id) return ''; + const name = (knownName && String(knownName).trim()) || nameCache.get(id); + if (name && name.length) return '@' + name; + return id; +}; + +const userLink = (feedId, knownName) => { + if (!feedId) return null; + return a({ class: 'user-link', href: `/author/${encodeURIComponent(feedId)}` }, userLinkLabel(feedId, knownName)); +}; + +exports.userLink = userLink; +exports.userLinkLabel = userLinkLabel; const i18nBase = require("../client/assets/translations/i18n"); let selectedLanguage = "en"; diff --git a/nodejs-project/nodejs-project/src/views/maps_view.js b/nodejs-project/nodejs-project/src/views/maps_view.js index 062584ff..b8a88cad 100644 --- a/nodejs-project/nodejs-project/src/views/maps_view.js +++ b/nodejs-project/nodejs-project/src/views/maps_view.js @@ -2,7 +2,7 @@ const { form, button, div, h2, h3, p, section, input, label, br, a, span, textar require("../server/node_modules/hyperaxe"); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer"); const { sanitizeHtml } = require('../backend/sanitizeHtml'); @@ -313,7 +313,7 @@ const renderMarkersList = (markers, mapObj) => { span({ class: "map-marker-dot" }, "ꔌ"), span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`), span({ class: "map-marker-meta" }, - a({ href: `/author/${encodeURIComponent(mk.author)}`, class: "user-link" }, mk.author), + userLink(mk.author), ` · ${moment(mk.createdAt).fromNow()}`)) ]))); }; @@ -351,7 +351,7 @@ const renderMapCard = (mapObj, filter, params = {}) => { p({ class: "card-footer" }, span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()), span(" · "), - a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author)))); + userLink(mapObj.author)))); }; const renderMapList = (maps, filter, params = {}) => @@ -433,7 +433,7 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => { br(), p({ class: "card-footer" }, span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author), + userLink(mapObj.author), mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt ? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`) : null), diff --git a/nodejs-project/nodejs-project/src/views/market_view.js b/nodejs-project/nodejs-project/src/views/market_view.js index c2ea0b78..231dda93 100644 --- a/nodejs-project/nodejs-project/src/views/market_view.js +++ b/nodejs-project/nodejs-project/src/views/market_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderUrl } = require("../backend/renderUrl") @@ -138,10 +138,15 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => { button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) ) ), - comments && comments.length + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text + return t && String(t).trim() + }) + return visibleComments.length ? div( { class: "comments-list" }, - comments.map((c) => { + visibleComments.map((c) => { const author = c.value && c.value.author ? c.value.author : "" const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" @@ -166,6 +171,7 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => { }) ) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + })() ) } @@ -678,7 +684,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => { renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`), renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom), item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")}`) : null, - renderCardFieldRich(`${i18n.marketItemSeller}:`, [a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)]) + renderCardFieldRich(`${i18n.marketItemSeller}:`, [userLink(item.seller)]) ), item.item_type === "auction" ? div( diff --git a/nodejs-project/nodejs-project/src/views/modules_view.js b/nodejs-project/nodejs-project/src/views/modules_view.js index 5a048f88..074235cf 100644 --- a/nodejs-project/nodejs-project/src/views/modules_view.js +++ b/nodejs-project/nodejs-project/src/views/modules_view.js @@ -7,6 +7,7 @@ const modulesView = () => { const modules = [ { name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription }, { name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription }, + { name: 'aiNav', label: i18n.modulesAINavLabel, description: i18n.modulesAINavDescription }, { name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription }, { name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription }, { name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription }, @@ -20,6 +21,7 @@ const modulesView = () => { { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription }, { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription }, { name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription }, + { name: 'graphos', label: i18n.modulesGraphosLabel, description: i18n.modulesGraphosDescription }, { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription }, { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription }, { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription }, @@ -86,7 +88,7 @@ const modulesView = () => { full: modules.map(m => m.name) }; - const presetButtons = div({ class: 'preset-group mode-buttons', style: 'margin-bottom:16px;' }, + const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' }, Object.entries(PRESETS).map(([key, mods]) => { const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase(); const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on')); diff --git a/nodejs-project/nodejs-project/src/views/opinions_view.js b/nodejs-project/nodejs-project/src/views/opinions_view.js index 4a7a6550..9a1a957a 100644 --- a/nodejs-project/nodejs-project/src/views/opinions_view.js +++ b/nodejs-project/nodejs-project/src/views/opinions_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink} = require('./main_views'); const { config } = require('../server/SSB_server.js'); const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); const { renderUrl } = require('../backend/renderUrl'); @@ -215,11 +215,11 @@ const renderContentHtml = (content, key) => { ), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.from + ':'), - span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from)) + span({ class: 'card-value' }, userLink(content.from)) ), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.to + ':'), - span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to)) + span({ class: 'card-value' }, userLink(content.to)) ), h2({ class: 'card-field' }, span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `), @@ -273,7 +273,7 @@ exports.opinionsView = (items, filter) => { contentHtml, p({ class: 'card-footer' }, span({ class: 'date-link' }, `${created} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author) + userLink(item.value.author) ), (() => { const entries = voteEntries.filter(([, v]) => v > 0); diff --git a/nodejs-project/nodejs-project/src/views/pads_view.js b/nodejs-project/nodejs-project/src/views/pads_view.js index 1cc2baa7..fa9affa9 100644 --- a/nodejs-project/nodejs-project/src/views/pads_view.js +++ b/nodejs-project/nodejs-project/src/views/pads_view.js @@ -1,5 +1,5 @@ const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") @@ -226,7 +226,7 @@ exports.singlePadView = async (pad, entries, params) => { ), table({ class: "tribe-info-table" }, tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))), - isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))), + isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(pad.author))), tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))), isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014")) ), @@ -296,15 +296,16 @@ exports.singlePadView = async (pad, entries, params) => { ) : p(i18n.padNoEntries || "No entries yet.") - const versionList = entries.length > 0 + const visibleEntries = entries.filter(e => e.text && String(e.text).trim()) + const versionList = visibleEntries.length > 0 ? div({ class: "pad-version-list" }, h4(i18n.padVersionHistory || "Version History"), - ...entries.slice().reverse().map((e, idx) => + ...visibleEntries.slice().reverse().map((e, idx) => div({ class: "pad-version-item" }, span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")), span({ class: "pad-version-author" }, span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }), - a({ href: `/author/${encodeURIComponent(e.author)}`, class: "user-link" }, "@" + e.author.slice(1, 9) + "\u2026") + userLink(e.author) ), a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View") ) diff --git a/nodejs-project/nodejs-project/src/views/parliament_view.js b/nodejs-project/nodejs-project/src/views/parliament_view.js index 620cf7cb..dadc79d1 100644 --- a/nodejs-project/nodejs-project/src/views/parliament_view.js +++ b/nodejs-project/nodejs-project/src/views/parliament_view.js @@ -1,6 +1,6 @@ const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink} = require('./main_views'); const TERM_DAYS = 60; @@ -131,7 +131,7 @@ const GovernmentCard = (g, meta) => { const actorLink = g.powerType === 'tribe' ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) - : a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId); + : userLink(g.powerId, g.powerTitle); const actorBio = meta && meta.bio ? meta.bio : ''; const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []); const membersRow = @@ -143,7 +143,7 @@ const GovernmentCard = (g, meta) => { div( span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()), memberIds && memberIds.length - ? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)))) + ? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(userLink(id)))) : span({ class: 'card-value' }, String(g.members || 0)) ) ) @@ -247,7 +247,7 @@ const CandidatureStats = (cands, govCard, leaderMeta) => { const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase(); const idLink = leader ? (leader.targetType === 'inhabitant' - ? a({ class: 'user-link', href: `/author/${encodeURIComponent(leader.targetId)}` }, leader.targetId) + ? userLink(leader.targetId) : a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId)) : null; return div( @@ -287,7 +287,7 @@ const CandidaturesTable = (candidatures) => { const rows = (candidatures || []).map(c => { const idLink = c.targetType === 'inhabitant' - ? p(a({ class: 'user-link break-all', href: `/author/${encodeURIComponent(c.targetId)}` }, c.targetId)) + ? p(userLink(c.targetId)) : p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId)); return tr( td(idLink), @@ -349,7 +349,7 @@ const ProposalsList = (proposals) => { div( { class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), - span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer)) + span({ class: 'card-value' }, userLink(pItem.proposer)) ), div( { class: 'card-field' }, @@ -419,7 +419,7 @@ const FutureLawsList = (rows) => { br(), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), - div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))), h2(pItem.title || ''), p(pItem.description || '') ) @@ -480,7 +480,7 @@ const RevocationsList = (revocations) => { span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span( { class: 'card-value' }, - a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer) + userLink(pItem.proposer) ) ), div( @@ -551,7 +551,7 @@ const FutureRevocationsList = (rows) => { br(), pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null, div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), - div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))), h2(pItem.title || pItem.lawTitle || ''), p(pItem.reasons || '') ) @@ -605,7 +605,7 @@ const LawsList = (laws) => { br(), div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)), div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))), - div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(l.proposer)}` }, l.proposer))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(l.proposer))), h2(l.question || ''), p(l.description || ''), showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null, @@ -666,7 +666,7 @@ const HistoricalList = (rows, metasByKey = {}) => { span({ class: 'card-value' }, g.powerType === 'tribe' ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) - : a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) + : userLink(g.powerId, g.powerTitle) ) ) : null, (g.method !== 'ANARCHY') @@ -785,7 +785,7 @@ const LeadersList = (leaders, metas = {}, candidatures = []) => { const avatar = meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' }) : null; const link = l.powerType === 'tribe' ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId) - : a({ class: 'user-link', href: `/author/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId); + : userLink(l.powerId, l.powerTitle); const leaderCell = div({ class: 'leader-cell' }, avatar, link); return tr( td(leaderCell), diff --git a/nodejs-project/nodejs-project/src/views/peers_view.js b/nodejs-project/nodejs-project/src/views/peers_view.js index f58e4d94..0415df78 100644 --- a/nodejs-project/nodejs-project/src/views/peers_view.js +++ b/nodejs-project/nodejs-project/src/views/peers_view.js @@ -23,10 +23,10 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => { const { name, users, key } = peer; const peerUrl = `/author/${encodeURIComponent(key)}`; const filteredUsers = (users || []).filter(u => u.id !== key); - const userCount = filteredUsers.length || peer.announcers || 0; + const userCount = filteredUsers.length; return tr( td(a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')), - td(span({ style: 'word-break:break-all;font-size:12px;color:#888;' }, key)), + td(a({ href: peerUrl, class: 'user-link peer-key' }, key)), td(String(userCount)) ); }; @@ -35,19 +35,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => { const dedupDiscovered = deduplicatePeers(discoveredPeers); const dedupUnknown = deduplicatePeers(unknownPeers); - const countPeers = (list) => { - let usersTotal = 0; - for (const item of list) { - const peerKey = item[1].key; - const users = (item[1].users || []).filter(u => u.id !== peerKey); - usersTotal += users.length || item[1].announcers || 0; - } - return list.length + usersTotal; - }; - - const onlineCount = countPeers(dedupOnline); - const discoveredCount = countPeers(dedupDiscovered); - const unknownCount = countPeers(dedupUnknown); + const onlineCount = dedupOnline.length; + const discoveredCount = dedupDiscovered.length; + const unknownCount = dedupUnknown.length; const renderPeerTable = (peers) => { if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered); @@ -55,7 +45,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => { tr( td({ class: 'card-label' }, i18n.peerHost || 'Pub'), td({ class: 'card-label' }, 'Key'), - td({ class: 'card-label' }, i18n.inhabitants || 'Inhabitants') + td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated feeds') ), ...peers.map(renderPeerRow) ); diff --git a/nodejs-project/nodejs-project/src/views/pixelia_view.js b/nodejs-project/nodejs-project/src/views/pixelia_view.js index 890aa5b2..8cc81aed 100644 --- a/nodejs-project/nodejs-project/src/views/pixelia_view.js +++ b/nodejs-project/nodejs-project/src/views/pixelia_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink} = require('./main_views'); exports.pixeliaView = (pixelArt, errorMessage) => { const title = i18n.pixeliaTitle; @@ -81,7 +81,7 @@ exports.pixeliaView = (pixelArt, errorMessage) => { h2(i18n.contributorsTitle), ul( ...contributors.map(author => - li(a({ class: 'user-link', href: `/author/${encodeURIComponent(author)}` }, author)) + li(userLink(author)) ) ) ) : null diff --git a/nodejs-project/nodejs-project/src/views/projects_view.js b/nodejs-project/nodejs-project/src/views/projects_view.js index b3cf3515..03b883cc 100644 --- a/nodejs-project/nodejs-project/src/views/projects_view.js +++ b/nodejs-project/nodejs-project/src/views/projects_view.js @@ -1,5 +1,5 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderUrl } = require("../backend/renderUrl") @@ -142,7 +142,7 @@ const renderFollowers = (project) => { return div( { class: "followers-block" }, h2(i18n.projectFollowersTitle), - ul(show.map((uid) => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: "user-link" }, uid)))), + ul(show.map((uid) => li(userLink(uid)))), followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null ) } @@ -171,7 +171,7 @@ const renderBackers = (project, filter) => { ...backers.slice(0, 8).map((b) => tr( td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""), - td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: "user-link" }, b.userId)), + td(userLink(b.userId)), td(`${b.amount} ECO`) ) ) @@ -285,7 +285,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => { ), safeText(b.description) ? p(...renderUrl(b.description)) : null, renderCardField(i18n.projectBountyStatus + ":", statusText), - b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null, + b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null, !editable && !b.done && !b.claimedBy && project.author !== userId ? form( { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` }, @@ -344,7 +344,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => { ), safeText(b.description) ? p(...renderUrl(b.description)) : null, renderCardField(i18n.projectBountyStatus + ":", statusText), - b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null, + b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null, !editable && !b.done && !b.claimedBy && project.author !== userId ? form( { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` }, @@ -588,7 +588,7 @@ const renderProjectList = (projects, filter) => { div( { class: "card-footer" }, span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author) + userLink(pr.author) ) ) }) @@ -745,7 +745,7 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => { div( { class: "card-footer" }, span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author) + userLink(pr.author) ) ), div( @@ -760,23 +760,29 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => { button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) ) ), - comments && comments.length + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text + return t && String(t).trim() + }) + return visibleComments.length ? div( { class: "comments-list" }, - comments.map((c) => { + visibleComments.map((c) => { const author = c?.value?.author || "" const ts = c?.value?.timestamp || c?.timestamp const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" const relDate = ts ? moment(ts).fromNow() : "" return div( { class: "comment-card" }, - div({ class: "comment-header" }, a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, author)), + div({ class: "comment-header" }, userLink(author)), div({ class: "comment-date" }, span({ title: absDate }, relDate)), div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || "")) ) }) ) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + })() ) ) } diff --git a/nodejs-project/nodejs-project/src/views/report_view.js b/nodejs-project/nodejs-project/src/views/report_view.js index 0178bbe6..75ab359f 100644 --- a/nodejs-project/nodejs-project/src/views/report_view.js +++ b/nodejs-project/nodejs-project/src/views/report_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const { config } = require("../server/SSB_server.js"); const moment = require("../server/node_modules/moment"); const { renderUrl } = require("../backend/renderUrl"); @@ -215,10 +215,15 @@ const renderReportCommentsSection = (reportId, comments = []) => { button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) ) ), - comments && comments.length + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + return visibleComments.length ? div( { class: "comments-list" }, - comments.map((c) => { + visibleComments.map((c) => { const author = c.value && c.value.author ? c.value.author : ""; const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; @@ -244,7 +249,8 @@ const renderReportCommentsSection = (reportId, comments = []) => { ); }) ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); + })() ); }; @@ -404,7 +410,7 @@ const renderReportCard = (report, userId, currentFilter = "all") => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), - a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author) + userLink(report.author) ) ); }; @@ -659,7 +665,7 @@ exports.singleReportView = async (report, filter, comments = []) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), - a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author) + userLink(report.author) ) ), renderReportCommentsSection(report.id, comments) diff --git a/nodejs-project/nodejs-project/src/views/search_view.js b/nodejs-project/nodejs-project/src/views/search_view.js index 1482239a..b8771bba 100644 --- a/nodejs-project/nodejs-project/src/views/search_view.js +++ b/nodejs-project/nodejs-project/src/views/search_view.js @@ -1,5 +1,5 @@ const { form, button, div, h2, p, section, input, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink} = require('./main_views'); const moment = require("../server/node_modules/moment"); const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); const { renderUrl } = require('../backend/renderUrl'); @@ -120,7 +120,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'about': return div({ class: 'search-about' }, content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null, content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null ); case 'feed': { @@ -190,23 +190,15 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ); case 'tribe': return div({ class: 'search-tribe' }, - content.title ? h2(content.title) : null, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, (() => { const s = String(content.image || '').trim().replace(/&/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(), - br(), - content.description ? content.description : null, - br(),br(), - div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' }, - content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null, - p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`), - content.inviteMode ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeModeLabel}: ${content.inviteMode.toUpperCase()}`) : null, - p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLARPLabel}: ${content.isLARP ? i18n.tribeYes : i18n.tribeNo}`) - ), + content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null, + content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)), + content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel + ':'), span({ class: 'card-value' }, content.isLARP ? i18n.tribeYes : i18n.tribeNo)), Array.isArray(content.members) - ? div({}, - div({ class: 'card-field' }, - h2(`${i18n.tribeMembersCount}: ${content.members.length}`), - ) - ) + ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, String(content.members.length))) : null, content.tags && content.tags.length ? div({ class: 'card-tags' }, content.tags.map(tag => @@ -274,8 +266,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ); case 'torrent': return div({ class: 'search-torrent' }, - content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null, + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null, + content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null, content.tags && content.tags.length ? div({ class: 'card-tags' }, content.tags.map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) @@ -292,7 +285,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = br(), blobImg(content.image), br(), - content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.seller)}` }, content.seller))) : null, + content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null, content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null, content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null, content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null, @@ -325,8 +318,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ); case 'bookmark': return div({ class: 'search-bookmark' }, - content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), - content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,br(), + content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null, content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null, content.tags && content.tags.length @@ -376,8 +369,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null, content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null, br(), - content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.from)}` }, content.from))) : null, - content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.to)}` }, content.to))) : null, + content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, userLink(content.from))) : null, + content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, userLink(content.to))) : null, br(), content.confirmedBy && content.confirmedBy.length ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length)) @@ -457,8 +450,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ); case 'forum': return div({ class: 'search-forum' }, - content.root ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title || '')) : div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title || '')), - content.text ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.text)) : null + content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, + content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null, + content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null ); case 'vote': return div({ class: 'search-vote-link' }, @@ -527,16 +521,6 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ) ) : null ); - case 'torrent': - return div({ class: 'search-torrent' }, - content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null, - content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null, - content.tags && content.tags.length - ? div({ class: 'card-tags' }, content.tags.map(tag => - a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) - )) - : null - ); case 'map': return div({ class: 'search-map' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, @@ -604,7 +588,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = author ? p({ class: 'card-footer' }, span({ class: 'date-link' }, `${created} ${i18n.performed} `), - a({ href: authorUrl, class: 'user-link' }, `${author}`) + (authorUrl && authorUrl !== '#' && authorUrl.startsWith('/author/')) + ? userLink(decodeURIComponent(authorUrl.replace(/^\/author\//, ''))) + : a({ href: authorUrl, class: 'user-link' }, `${author}`) ): null, ]); }) diff --git a/nodejs-project/nodejs-project/src/views/shops_view.js b/nodejs-project/nodejs-project/src/views/shops_view.js index 553e2281..1606cfff 100644 --- a/nodejs-project/nodejs-project/src/views/shops_view.js +++ b/nodejs-project/nodejs-project/src/views/shops_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderUrl } = require("../backend/renderUrl") @@ -327,7 +327,7 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString()) ), tr( - td({ class: "tribe-info-value", colspan: "4" }, a({ class: "user-link", href: `/author/${encodeURIComponent(shop.author)}` }, shop.author)) + td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author)) ), shop.location ? tr( td({ class: "tribe-info-label" }, i18n.shopLocation), @@ -458,7 +458,7 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => p({ class: "card-footer" }, span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")), " ", - a({ href: `/author/${encodeURIComponent(product.author)}`, class: "user-link" }, product.author) + userLink(product.author) ), !isAuthor && safeArr(product.buyers).includes(userId) && !safeArr(product.opinions_inhabitants).includes(userId) ? div({ class: "voting-buttons transfer-voting-buttons" }, diff --git a/nodejs-project/nodejs-project/src/views/stats_view.js b/nodejs-project/nodejs-project/src/views/stats_view.js index 881eb274..494a8b0e 100644 --- a/nodejs-project/nodejs-project/src/views/stats_view.js +++ b/nodejs-project/nodejs-project/src/views/stats_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, tr, td, th } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require('./main_views'); +const { template, i18n, userLink } = require('./main_views'); Object.assign(i18n, { statsChat: "Chats", @@ -26,6 +26,11 @@ Object.assign(i18n, { const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0); const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0); +const wClass = (pct) => { + const n = Math.max(0, Math.min(100, Math.round((pct || 0) / 5) * 5)); + return `stats-w-${n}`; +}; + exports.statsView = (stats, filter) => { const title = i18n.statsTitle; const description = i18n.statsDescription; @@ -86,8 +91,369 @@ exports.statsView = (stats, filter) => { }; const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0); const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0); - const blockStyle = 'padding:16px;border:1px solid #ddd;border-radius:8px;margin-bottom:24px;'; - const headerStyle = 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);'; + + const fmtNum = (n) => { + if (typeof n !== 'number' || !isFinite(n)) return '0'; + if (Math.abs(n) >= 100) return n.toFixed(0); + if (Math.abs(n) >= 10) return n.toFixed(1); + return n.toFixed(2); + }; + + const kpi = (label, value) => div({ class: 'stats-kpi' }, + div({ class: 'stats-kpi-label' }, label), + div({ class: 'stats-kpi-value' }, String(value)) + ); + + const kpiBar = (label, value, pct) => { + const n = Math.max(0, Math.min(100, Number(pct) || 0)); + return div({ class: 'stats-kpi' }, + div({ class: 'stats-kpi-label' }, label), + div({ class: 'stats-kpi-value' }, String(value)), + n > 0 + ? div({ class: 'stats-bar-track stats-kpi-bar' }, + div({ class: `stats-bar-fill ${wClass(n)}` }) + ) + : null + ); + }; + + const kpiGrid = (...tiles) => div({ class: 'stats-grid' }, tiles.filter(Boolean)); + + const renderTopList = (items, getName, getCount, max) => { + if (!items || !items.length) return p({ class: 'no-content' }, i18n.no_results || 'No data'); + const m = Math.max(1, max || items[0] && getCount(items[0]) || 1); + return ul({ class: 'stats-toplist' }, + ...items.map(it => { + const cnt = getCount(it); + const pct = (cnt / m) * 100; + return li( + span({ class: 'stats-toplist-name' }, getName(it)), + div({ class: 'stats-bar-track' }, + div({ class: `stats-bar-fill ${wClass(pct)}` }) + ), + span({ class: 'stats-toplist-num' }, String(cnt)) + ); + }) + ); + }; + + const carbonChart = (() => { + const parseSize = (s) => { + if (!s) return 0; + const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i); + if (!m) return 0; + const v = parseFloat(m[1]); + const u = m[2].toUpperCase(); + if (u === 'GB') return v * 1024; + if (u === 'MB') return v; + if (u === 'KB') return v / 1024; + return v / (1024 * 1024); + }; + const blobsMB = parseSize(stats.statsBlobsSize); + const chainMB = parseSize(stats.statsBlockchainSize); + const totalMB = blobsMB + chainMB; + const kWhPerMB = 0.0002; + const gCO2PerKWh = 475; + const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2)); + const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1; + const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2)); + const maxAnnualCO2 = 500; + + if (filter === 'MINE') { + const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0; + return div({ class: 'carbon-chart' }, + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonUser || 'Your footprint'), + span(`${userCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` }) + ), + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonNetwork || 'Network total'), + span(`${networkCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' }) + ), + p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`), + p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) + ); + } + if (filter === 'TOMBSTONE') { + const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0; + const avgTombBytes = 500; + const tombMB = (tombCount * avgTombBytes) / (1024 * 1024); + const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4)); + const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0; + return div({ class: 'carbon-chart' }, + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonTombstone || 'Tombstoning footprint'), + span(`${tombCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` }) + ), + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonNetwork || 'Network total'), + span(`${networkCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' }) + ), + p({ class: 'carbon-bar-note' }, strong(`${tombPct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`), + p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) + ); + } + const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100); + return div({ class: 'carbon-chart' }, + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonNetwork || 'Network footprint'), + span(`${networkCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` }) + ), + div({ class: 'carbon-bar-label' }, + span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'), + span(`${maxAnnualCO2} g CO₂`) + ), + div({ class: 'carbon-bar-track' }, + div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' }) + ), + p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`), + p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) + ); + })(); + + const headerCard = div({ class: 'stats-card' }, + table({ class: 'block-info-table' }, + tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)), + tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))), + tr(td({ class: 'card-label' }, i18n.statsBlobsSize), td({ class: 'card-value' }, stats.statsBlobsSize)), + tr(td({ class: 'card-label' }, i18n.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)), + tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize)) + ) + ); + + const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0; + const networkKPIs = stats.networkKPIs || {}; + + const topStrip = div({ class: 'stats-block' }, + kpiGrid( + kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')), + kpi(i18n.statsUsersTitle, totalInhabitants), + kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0), + kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0), + kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0), + kpi(i18n.statsPUBs, stats.pubsCount || 0) + ) + ); + + const carbonCard = div({ class: 'stats-card' }, + h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'), + carbonChart + ); + + const bankingCard = div({ class: 'stats-card' }, + h3({ class: 'stats-section-h' }, i18n.statsBankingTitle), + table({ class: 'block-info-table' }, + tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured))), + tr(td({ class: 'card-label' }, i18n.statsTotalEcoAddresses), td({ class: 'card-value' }, String(stats?.banking?.totalAddresses || 0))) + ) + ); + + const networkBlock = div({ class: 'stats-block' }, + h2(i18n.statsNetworkKPIsTitle || 'Network KPIs'), + kpiGrid( + filter === 'MINE' + ? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`) + : null, + kpi(i18n.statsAvgPerInhabitant || 'Avg per inhabitant', fmtNum(networkKPIs.avgMsgsPerInhabitant || 0)), + kpi(i18n.statsMsgsPerDay || 'Messages/day (lifetime)', fmtNum(networkKPIs.networkMsgsPerDay || 0)), + kpi(i18n.statsNetworkSpan || 'Network span', `${fmtNum(networkKPIs.networkSpanDays || 0)} d`), + kpi(i18n.statsTombstoneRatioLabel || 'Tombstone ratio', `${fmtNum(stats.tombstoneKPIs?.ratio || 0)}%`) + ) + ); + + const activityBlock = (() => { + const rows = Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []; + const max = Math.max(1, ...rows.map(r => Number(r.count) || 0)); + return div({ class: 'stats-block' }, + h2(i18n.statsActivity7d), + rows.length + ? ul({ class: 'stats-toplist' }, + ...rows.map(row => { + const cnt = Number(row.count) || 0; + const pct = (cnt / max) * 100; + return li( + span({ class: 'stats-toplist-name' }, row.day), + div({ class: 'stats-bar-track' }, + div({ class: `stats-bar-fill ${wClass(pct)}` }) + ), + span({ class: 'stats-toplist-num' }, String(cnt)) + ); + }) + ) + : p({ class: 'no-content' }, i18n.no_results || 'No data'), + div({ class: 'stats-activity-totals' }, + span(`${i18n.statsActivity7dTotal}: `, strong(String(stats.activity?.daily7Total || 0))), + span(`${i18n.statsActivity30dTotal}: `, strong(String(stats.activity?.daily30Total || 0))) + ) + ); + })(); + + const topTypes = Array.isArray(stats.topTypes) ? stats.topTypes : []; + const topTypesBlock = topTypes.length ? div({ class: 'stats-block' }, + h2(i18n.statsTopTypesTitle || 'Top Content Types'), + renderTopList( + topTypes, + it => labels[it.type] || it.type, + it => it.count, + topTypes[0] ? topTypes[0].count : 1 + ) + ) : null; + + const topTags = Array.isArray(stats.topTags) ? stats.topTags : []; + const topTagsBlock = topTags.length ? div({ class: 'stats-block' }, + h2(i18n.statsTopTagsTitle || 'Top Tags'), + div({ class: 'stats-mb-16' }, + topTags.map(t => a({ class: 'stats-pill', href: `/search?query=%23${encodeURIComponent(t.tag)}` }, `#${t.tag} (${t.count})`)) + ) + ) : null; + + const marketBlock = div({ class: 'stats-block' }, + h2(i18n.statsMarketTitle), + kpiGrid( + kpi(i18n.statsMarketTotal, stats.marketKPIs?.total || 0), + kpi(i18n.statsMarketForSale, stats.marketKPIs?.forSale || 0), + kpi(i18n.statsMarketReserved, stats.marketKPIs?.reserved || 0), + kpi(i18n.statsMarketClosed, stats.marketKPIs?.closed || 0), + kpi(i18n.statsMarketSold, stats.marketKPIs?.sold || 0) + ) + ); + + const projectsBlock = div({ class: 'stats-block' }, + h2(i18n.statsProjectsTitle), + kpiGrid( + kpi(i18n.statsProjectsTotal, stats.projectsKPIs?.total || 0), + kpi(i18n.statsProjectsActive, stats.projectsKPIs?.active || 0), + kpi(i18n.statsProjectsCompleted, stats.projectsKPIs?.completed || 0), + kpi(i18n.statsProjectsPaused, stats.projectsKPIs?.paused || 0), + kpi(i18n.statsProjectsCancelled, stats.projectsKPIs?.cancelled || 0), + kpi(i18n.statsProjectsGoalTotal, `${stats.projectsKPIs?.ecoGoalTotal || 0} ECO`), + kpi(i18n.statsProjectsPledgedTotal, `${stats.projectsKPIs?.ecoPledgedTotal || 0} ECO`) + ) + ); + + const allTribesPublic = Array.isArray(stats.allTribesPublic) ? stats.allTribesPublic : []; + const memberTribesDetailed = Array.isArray(stats.memberTribesDetailed) ? stats.memberTribesDetailed : []; + const myPrivateTribesDetailed = Array.isArray(stats.myPrivateTribesDetailed) ? stats.myPrivateTribesDetailed : []; + + const buildContentTiles = () => { + const tiles = []; + types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage' && t !== 'calendarDate' && t !== 'calendarNote').forEach(t => { + const cnt = C(stats, t); + if (cnt <= 0) return; + tiles.push(kpi(labels[t], cnt)); + if (t === 'shop') tiles.push(kpi(labels.shopProduct, C(stats, 'shopProduct'))); + else if (t === 'pad') tiles.push(kpi(labels.padEntry, C(stats, 'padEntry'))); + else if (t === 'chat') tiles.push(kpi(labels.chatMessage, C(stats, 'chatMessage'))); + else if (t === 'calendar') { + tiles.push(kpi(labels.calendarDate, C(stats, 'calendarDate'))); + tiles.push(kpi(labels.calendarNote, C(stats, 'calendarNote'))); + } else if (t === 'tribe') { + tiles.push(kpi(i18n.statsPublic, stats.tribePublicCount || 0)); + tiles.push(kpi(i18n.statsPrivate, stats.tribePrivateCount || 0)); + } + }); + return tiles; + }; + + const buildOpinionTiles = () => + types.map(t => O(stats, t) > 0 ? kpi(labels[t], O(stats, t)) : null).filter(Boolean); + + const tribeListBlock = (label, list) => div({ class: 'stats-block' }, + h2(`${label}: ${list.length}`), + list.length + ? table({ class: 'stats-table-mt8' }, + ...list.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name)))) + ) + : p({ class: 'no-content' }, i18n.no_results || 'No data') + ); + + const allMode = filter === 'ALL' + ? div({ class: 'stats-container' }, [ + networkBlock, + activityBlock, + topTypesBlock, + topTagsBlock, + div({ class: 'stats-block' }, + h2(i18n.statsNetworkContent), + kpiGrid( + kpi(i18n.statsDiscoveredTribes, allTribesPublic.length), + kpi(i18n.statsPrivateDiscoveredTribes, stats.tribePrivateCount || 0), + kpi(i18n.statsDiscoveredForum, C(stats, 'forum')), + kpi(i18n.statsDiscoveredTransfer, C(stats, 'transfer')) + ) + ), + tribeListBlock(i18n.statsDiscoveredTribes, allTribesPublic), + marketBlock, + projectsBlock, + div({ class: 'stats-block' }, + h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`), + kpiGrid(...buildOpinionTiles()) + ), + div({ class: 'stats-block' }, + h2(`${i18n.statsNetworkContent}: ${totalContent}`), + kpiGrid(...buildContentTiles()) + ) + ]) + : null; + + const mineMode = filter === 'MINE' + ? div({ class: 'stats-container' }, [ + networkBlock, + activityBlock, + topTypesBlock, + topTagsBlock, + div({ class: 'stats-block' }, + h2(i18n.statsYourContent || i18n.statsNetworkContent), + kpiGrid( + kpi(i18n.statsDiscoveredTribes, memberTribesDetailed.length), + kpi(i18n.statsPrivateDiscoveredTribes, myPrivateTribesDetailed.length), + kpi(i18n.statsYourForum, C(stats, 'forum')), + kpi(i18n.statsYourTransfer, C(stats, 'transfer')) + ) + ), + tribeListBlock(i18n.statsDiscoveredTribes, memberTribesDetailed), + myPrivateTribesDetailed.length + ? tribeListBlock(i18n.statsPrivateDiscoveredTribes, myPrivateTribesDetailed) + : null, + marketBlock, + projectsBlock, + div({ class: 'stats-block' }, + h2(`${i18n.statsYourOpinions}: ${totalOpinions}`), + kpiGrid(...buildOpinionTiles()) + ), + div({ class: 'stats-block' }, + h2(`${i18n.statsYourContent}: ${totalContent}`), + kpiGrid(...buildContentTiles()) + ) + ]) + : null; + + const tombMode = filter === 'TOMBSTONE' + ? div({ class: 'stats-container' }, [ + div({ class: 'stats-block' }, + kpiGrid( + kpi(i18n.TOMBSTONEButton, stats.userTombstoneCount || 0), + kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`) + ) + ) + ]) + : null; + return template( title, section( @@ -95,7 +461,7 @@ exports.statsView = (stats, filter) => { h2(title), p(description) ), - div({ class: 'mode-buttons stats-grid' }, + div({ class: 'mode-buttons stats-mode-row' }, modes.map(m => form({ method: 'GET', action: '/stats' }, input({ type: 'hidden', name: 'filter', value: m }), @@ -104,311 +470,14 @@ exports.statsView = (stats, filter) => { ) ), section( - div({ style: headerStyle }, - h3({ class: 'stats-h-row' }, `${i18n.statsCreatedAt}: `, span({ class: 'stats-muted-888' }, stats.createdAt)), - h3({ class: 'stats-section-h' }, - a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, class: 'stats-link' }, stats.id) - ), - div({ class: 'stats-mb-16' }, - ul({ class: 'stats-list-reset' }, - li({ class: 'stats-h-row' }, `${i18n.statsBlobsSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlobsSize)), - li({ class: 'stats-h-row' }, `${i18n.statsBlockchainSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlockchainSize)), - li({ class: 'stats-h-row' }, strong(`${i18n.statsSize}: `, span({ class: 'stats-muted-888' }, span({ class: 'stats-muted-555' }, stats.folderSize)))) - ) - ) - ), - div({ class: "stats-karma-block" }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)), - div({ style: headerStyle }, - h3(i18n.statsCarbonFootprintTitle || 'Carbon Footprint'), - (() => { - const parseSize = (s) => { - if (!s) return 0; - const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i); - if (!m) return 0; - const v = parseFloat(m[1]); - const u = m[2].toUpperCase(); - if (u === 'GB') return v * 1024; - if (u === 'MB') return v; - if (u === 'KB') return v / 1024; - return v / (1024 * 1024); - }; - const blobsMB = parseSize(stats.statsBlobsSize); - const chainMB = parseSize(stats.statsBlockchainSize); - const totalMB = blobsMB + chainMB; - const kWhPerMB = 0.0002; - const gCO2PerKWh = 475; - const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2)); - const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1; - const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2)); - const maxAnnualCO2 = 500; - - if (filter === 'MINE') { - const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100).toFixed(1) : '0.0'; - return div({ class: 'carbon-chart' }, - div({ class: 'carbon-bar-label' }, - span(i18n.statsCarbonUser || 'Your footprint'), - span(`${userCO2} g CO₂`) - ), - div({ class: 'carbon-bar-track' }, - div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${pct}%;` }) - ), - div({ class: 'carbon-bar-label' }, - span(i18n.statsCarbonNetwork || 'Network total'), - span(`${networkCO2} g CO₂`) - ), - div({ class: 'carbon-bar-track' }, - div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' }) - ), - p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`), - p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) - ); - } - if (filter === 'TOMBSTONE') { - const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0; - const avgTombBytes = 500; - const tombMB = (tombCount * avgTombBytes) / (1024 * 1024); - const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4)); - const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100).toFixed(1) : '0.0'; - return div({ class: 'carbon-chart' }, - div({ class: 'carbon-bar-label' }, - span(i18n.statsCarbonTombstone || 'Tombstoning footprint'), - span(`${tombCO2} g CO₂`) - ), - div({ class: 'carbon-bar-track' }, - div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${tombPct}%;` }) - ), - div({ class: 'carbon-bar-label' }, - span(i18n.statsCarbonNetwork || 'Network total'), - span(`${networkCO2} g CO₂`) - ), - div({ class: 'carbon-bar-track' }, - div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' }) - ), - p({ class: 'carbon-bar-note' }, strong(`${tombPct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`), - p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) - ); - } - const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100).toFixed(1); - return div({ class: 'carbon-chart' }, - div({ class: 'carbon-bar-label' }, - span(i18n.statsCarbonNetwork || 'Network footprint'), - span(`${networkCO2} g CO₂`) - ), - div({ class: 'carbon-bar-track' }, - div({ class: 'carbon-bar-fill carbon-bar-network', style: `width:${pct}%;` }) - ), - div({ class: 'carbon-bar-label' }, - span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'), - span(`${maxAnnualCO2} g CO₂`) - ), - div({ class: 'carbon-bar-track' }, - div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' }) - ), - p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`), - p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)')) - ); - })() - ), - div({ style: headerStyle }, - h3({ class: 'stats-section-h' }, i18n.statsBankingTitle), - ul({ class: 'stats-list-reset' }, - li({ class: 'stats-h-row' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)), - li({ class: 'stats-h-row' }, `${i18n.statsTotalEcoAddresses}: `, span({ class: 'stats-muted-888' }, String(stats?.banking?.totalAddresses || 0))) - ) - ), - div({ style: headerStyle }, - h3({ class: 'stats-section-h' }, i18n.statsLogsTitle || 'Logs'), - ul({ class: 'stats-list-reset' }, - li({ class: 'stats-h-row' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ class: 'stats-muted-888' }, String(stats?.logsCount || 0))) - ) - ), - div({ style: headerStyle }, - h3({ class: 'stats-section-h' }, i18n.statsAITraining), - ul({ class: 'stats-list-reset' }, - li({ class: 'stats-h-row' }, `${i18n.statsAIExchanges}: `, span({ class: 'stats-muted-888' }, String(C(stats, 'aiExchange') || 0))) - ) - ), - div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)), - filter === 'ALL' - ? div({ class: 'stats-container' }, [ - div({ style: blockStyle }, - h2(i18n.statsActivity7d), - table({ class: 'stats-table' }, - tr(th(i18n.day), th(i18n.messages)), - ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count)))) - ), - p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`), - p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`) - ), - div({ style: blockStyle }, - h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`), - table({ class: 'stats-table-mt8' }, - ...stats.allTribesPublic.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name)))) - ) - ), - div({ style: blockStyle }, - h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.tribePrivateCount || 0}`) - ), - div({ style: blockStyle }, h2(`${i18n.statsUsersTitle}: ${stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0}`)), - div({ style: blockStyle }, h2(`${i18n.statsDiscoveredForum}: ${C(stats, 'forum')}`)), - div({ style: blockStyle }, h2(`${i18n.statsDiscoveredTransfer}: ${C(stats, 'transfer')}`)), - div({ style: blockStyle }, - h2(i18n.statsMarketTitle), - ul([ - li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`), - li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`), - li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`), - li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`), - li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`), - li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`), - li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`) - ]) - ), - div({ style: blockStyle }, - h2(i18n.statsProjectsTitle), - ul([ - li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`), - li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`), - li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`), - li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`), - li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`), - li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`), - li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`), - li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`), - li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`), - li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`), - li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`) - ]) - ), - div({ style: blockStyle }, - h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`), - ul(types.map(t => O(stats, t) > 0 ? li(`${labels[t]}: ${O(stats, t)}`) : null).filter(Boolean)) - ), - div({ style: blockStyle }, - h2(`${i18n.statsNetworkContent}: ${totalContent}`), - ul( - types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage').map(t => { - if (C(stats, t) <= 0) return null; - if (t === 'shop') return li( - span(`${labels[t]}: ${C(stats, t)}`), - ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)]) - ); - if (t === 'pad') return li( - span(`${labels[t]}: ${C(stats, t)}`), - ul([li(`${labels.padEntry}: ${C(stats, 'padEntry')}`)]) - ); - if (t === 'chat') return li( - span(`${labels[t]}: ${C(stats, t)}`), - ul([li(`${labels.chatMessage}: ${C(stats, 'chatMessage')}`)]) - ); - if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`); - return li( - span(`${labels[t]}: ${C(stats, t)}`), - ul([ - li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`), - li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`) - ]) - ); - }).filter(Boolean) - ) - ) - ]) - : filter === 'MINE' - ? div({ class: 'stats-container' }, [ - div({ style: blockStyle }, - h2(i18n.statsActivity7d), - table({ class: 'stats-table' }, - tr(th(i18n.day), th(i18n.messages)), - ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count)))) - ), - p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`), - p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`) - ), - div({ style: blockStyle }, - h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`), - table({ class: 'stats-table-mt8' }, - ...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name)))) - ) - ), - Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length - ? div({ style: blockStyle }, - h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`), - table({ class: 'stats-table-mt8' }, - ...stats.myPrivateTribesDetailed.map(tp => tr(td(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name)))) - ) - ) - : null, - div({ style: blockStyle }, h2(`${i18n.statsYourForum}: ${C(stats, 'forum')}`)), - div({ style: blockStyle }, h2(`${i18n.statsYourTransfer}: ${C(stats, 'transfer')}`)), - div({ style: blockStyle }, - h2(i18n.statsMarketTitle), - ul([ - li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`), - li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`), - li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`), - li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`), - li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`), - li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`), - li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`) - ]) - ), - div({ style: blockStyle }, - h2(i18n.statsProjectsTitle), - ul([ - li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`), - li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`), - li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`), - li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`), - li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`), - li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`), - li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`), - li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`), - li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`), - li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`), - li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`) - ]) - ), - div({ style: blockStyle }, - h2(`${i18n.statsYourOpinions}: ${totalOpinions}`), - ul(types.map(t => O(stats, t) > 0 ? li(`${labels[t]}: ${O(stats, t)}`) : null).filter(Boolean)) - ), - div({ style: blockStyle }, - h2(`${i18n.statsYourContent}: ${totalContent}`), - ul( - types.filter(t => t !== 'karmaScore' && t !== 'shopProduct').map(t => { - if (C(stats, t) <= 0) return null; - if (t === 'shop') return li( - span(`${labels[t]}: ${C(stats, t)}`), - ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)]) - ); - if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`); - return li( - span(`${labels[t]}: ${C(stats, t)}`), - ul([ - li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`), - li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`), - ...(Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length - ? [ - li(i18n.statsPrivateDiscoveredTribes), - ...stats.myPrivateTribesDetailed.map(tp => - li(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name)) - ) - ] - : []) - ]) - ); - }).filter(Boolean) - ) - ) - ]) - : div({ class: 'stats-container' }, [ - div({ style: blockStyle }, - h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`), - h2(`${i18n.statsTombstoneRatio.toUpperCase()}: ${((stats.tombstoneKPIs?.ratio || 0)).toFixed(2)}%`) - ) - ]) + topStrip, + headerCard, + bankingCard, + carbonCard, + allMode, + mineMode, + tombMode ) ) ); }; - diff --git a/nodejs-project/nodejs-project/src/views/task_view.js b/nodejs-project/nodejs-project/src/views/task_view.js index fceeffca..6dfb609b 100644 --- a/nodejs-project/nodejs-project/src/views/task_view.js +++ b/nodejs-project/nodejs-project/src/views/task_view.js @@ -1,6 +1,6 @@ const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span } = require("../server/node_modules/hyperaxe"); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); @@ -162,10 +162,15 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) ) ), - comments && comments.length + (() => { + const visibleComments = (comments || []).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); + return visibleComments.length ? div( { class: "comments-list" }, - comments.map((c) => { + visibleComments.map((c) => { const author = c.value && c.value.author ? c.value.author : ""; const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; @@ -191,7 +196,8 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") ); }) ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) + : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); + })() ); }; @@ -221,7 +227,7 @@ const renderTaskItem = (task, filter) => { span( { class: "card-value" }, assignees.length - ? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat() + ? assignees.map((id, i) => [i > 0 ? ", " : "", userLink(id)]).flat() : i18n.noAssignees ) ), @@ -242,7 +248,7 @@ const renderTaskItem = (task, filter) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`) + userLink(task.author) ) ); }; @@ -447,7 +453,7 @@ exports.singleTaskView = async (task, filter, comments = []) => { span( { class: "card-value" }, assignees.length - ? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat() + ? assignees.map((id, i) => [i > 0 ? ", " : "", userLink(id)]).flat() : i18n.noAssignees ) ), @@ -455,7 +461,7 @@ exports.singleTaskView = async (task, filter, comments = []) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`) + userLink(task.author) ) ), renderTaskCommentsSection(task.id, comments, currentFilter) diff --git a/nodejs-project/nodejs-project/src/views/torrents_view.js b/nodejs-project/nodejs-project/src/views/torrents_view.js index b26e9d51..ab5d1779 100644 --- a/nodejs-project/nodejs-project/src/views/torrents_view.js +++ b/nodejs-project/nodejs-project/src/views/torrents_view.js @@ -19,7 +19,7 @@ const { td } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); @@ -180,7 +180,7 @@ const renderTorrentTable = (torrents, filter, params = {}) => { torrents.map((t) => tr( td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")), - td(a({ href: `/author/${encodeURIComponent(t.author)}`, class: "user-link" }, t.author)), + td(userLink(t.author)), td(t.title || ""), td(formatSize(t.size)), td( @@ -375,7 +375,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(torrentObj.author)}`, class: "user-link" }, `${torrentObj.author}`), + userLink(torrentObj.author), showUpdated ? span( { class: "votations-comment-date" }, diff --git a/nodejs-project/nodejs-project/src/views/transfer_view.js b/nodejs-project/nodejs-project/src/views/transfer_view.js index c3e268a7..849e8e8d 100644 --- a/nodejs-project/nodejs-project/src/views/transfer_view.js +++ b/nodejs-project/nodejs-project/src/views/transfer_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress } = require("../server/node_modules/hyperaxe") -const { template, i18n } = require("./main_views") +const { template, i18n, userLink} = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const opinionCategories = require("../backend/opinion_categories") @@ -188,7 +188,7 @@ const generateTransferCard = (transfer, filter, params = {}) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`), + userLink(transfer.from), renderUpdatedLabel(transfer.createdAt, transfer.updatedAt) ) ) @@ -397,8 +397,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => { div( { class: "card-section transfer" }, topbar ? topbar : null, - renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)), - renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)), + renderCardField(`${i18n.transfersFrom}:`, userLink(transfer.from)), + renderCardField(`${i18n.transfersTo}:`, userLink(transfer.to)), br, div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)), renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""), @@ -420,7 +420,7 @@ exports.singleTransferView = async (transfer, filter, params = {}) => { p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`), + userLink(transfer.from), renderUpdatedLabel(transfer.createdAt, transfer.updatedAt) ), div( diff --git a/nodejs-project/nodejs-project/src/views/video_view.js b/nodejs-project/nodejs-project/src/views/video_view.js index aab8f286..1bdbca56 100644 --- a/nodejs-project/nodejs-project/src/views/video_view.js +++ b/nodejs-project/nodejs-project/src/views/video_view.js @@ -17,7 +17,7 @@ const { } = require("../server/node_modules/hyperaxe"); const moment = require("../server/node_modules/moment"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl") const { renderMapLocationVisitLabel } = require("./maps_view"); @@ -116,7 +116,10 @@ const renderVideoOwnerActions = (filter, videoObj, params = {}) => { }; const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => { - const list = safeArr(comments); + const list = safeArr(comments).filter(c => { + const t = c && c.value && c.value.content && c.value.content.text; + return t && String(t).trim(); + }); const commentsCount = list.length; return div( @@ -231,7 +234,7 @@ const renderVideoList = (videos, filter, params = {}) => { return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`), + userLink(videoObj.author), showUpdated ? span( { class: "votations-comment-date" }, @@ -423,7 +426,7 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params return p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`), + userLink(videoObj.author), showUpdated ? span( { class: "votations-comment-date" }, diff --git a/nodejs-project/nodejs-project/src/views/vote_view.js b/nodejs-project/nodejs-project/src/views/vote_view.js index 8b129424..56e90025 100644 --- a/nodejs-project/nodejs-project/src/views/vote_view.js +++ b/nodejs-project/nodejs-project/src/views/vote_view.js @@ -1,5 +1,5 @@ const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe"); -const { template, i18n } = require("./main_views"); +const { template, i18n, userLink} = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const opinionCategories = require("../backend/opinion_categories"); @@ -230,7 +230,7 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, mode, activeFilter) p( { class: "card-footer" }, span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - a({ href: `/author/${encodeURIComponent(v.createdBy)}`, class: "user-link" }, `${v.createdBy}`) + userLink(v.createdBy) ), renderOpinionsBar(v, returnTo) );