diff --git a/CLAUDE.md b/CLAUDE.md index d375033cd..37b1c1447 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,7 @@ window.t('ui.polls.create.submit', { cost: 25 }, 'Create Poll ({cost} credits)') 3. Replace JS literals with `window.t(...)` (or `uiT(...)`) fallbacks, but do not invent a new inline fallback unless the corresponding catalog key was added first. 4. Ensure API errors return `error_code`. 5. Run both i18n check scripts before commit. -6. **API route changes**: When adding, removing, or modifying routes in `routes/api-routes.php`, update `docs/API.md` to reflect the change. +6. **API route changes**: When adding, removing, or modifying routes in `routes/api-routes.php`, update `docs/API.md`. Response tables must be complete: every field typed `object` or `array of objects` must have its sub-fields listed in the same table using dot-notation (`parent.child`) or bracket-notation (`items[].child`). A row typed `object` or `array` with no sub-rows is incomplete and must not be committed. See **Response table format** in `docs/DEVELOPER_GUIDE.md` for the full rules and an example. ## URL Construction diff --git a/README.md b/README.md index 7d56d0d59..c53cddbb3 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,12 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full component diagram, BinktermPHP supports two installation methods: -- **Installer (recommended)** — download and run `binkterm-installer.phar` for a guided, automated setup that handles PHP, PostgreSQL, web server configuration, and migrations -- **Git (for developers)** — clone the repository and run setup scripts for full control over the installation +- **Installer (recommended)** - download and run `binkterm-installer.phar` for a guided, automated setup that handles PHP, PostgreSQL, web server configuration, and migrations +- **Git (for developers)** - clone the repository and run setup scripts for full control over the installation -For complete installation instructions — system requirements, Ubuntu/Debian package setup, PostgreSQL configuration, web server configuration (Caddy, Nginx, Apache), cron job setup, and a network port reference — see **[docs/INSTALL.md](docs/INSTALL.md)**. +BinktermPHP is intended for a VPS, dedicated server, Raspberry Pi, or similar environment where you control PostgreSQL, background daemons, and multiple network ports. Shared hosting is not recommended. + +For complete installation instructions - system requirements, Ubuntu/Debian package setup, PostgreSQL configuration, web server configuration (Caddy, Nginx, Apache), cron job setup, and a network port reference - see **[docs/INSTALL.md](docs/INSTALL.md)**. --- diff --git a/config/bbs.json.example b/config/bbs.json.example index 81ce0780f..43e92ca51 100644 --- a/config/bbs.json.example +++ b/config/bbs.json.example @@ -9,7 +9,9 @@ "guest_doors_page": false, "bbs_directory": true, "public_files_index": false, - "qwk": true + "qwk": true, + "pgp": false, + "pgp_managed_keys": false }, "default_echo_interface": "echolist", "bulletin_display_mode": "once", diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index a41ce3914..07c29864f 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -1813,6 +1813,10 @@ 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Zeigt die öffentliche Seite /bbs-directory und das BBS-Listen-Menü an. Wenn deaktiviert, liefert die Seite 404.', 'ui.admin.bbs_settings.features.enable_qwk' => 'Enable QWK Offline Mail', 'ui.admin.bbs_settings.features.qwk_help' => 'Erlaubt Benutzern, QWK-Pakete herunterzuladen und REP-Antwortpakete für Offline-Mail hochzuladen.', + 'ui.admin.bbs_settings.features.enable_pgp' => 'PGP aktivieren', + 'ui.admin.bbs_settings.features.pgp_help' => 'Aktiviert die PGP-Schluesselverwaltung fuer Benutzer, den oeffentlichen Keyserver und HKP-aehnliche Suchendpunkte fuer oeffentliche Schluessel.', + 'ui.admin.bbs_settings.features.enable_pgp_managed_keys' => 'BBS-verwaltete private Schluessel erlauben', + 'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'Wenn aktiviert, koennen Benutzer browserseitig PGP-Schluesselpaare erzeugen und den verschluesselten privaten Schluessel auf diesem Server speichern.', 'ui.admin.bbs_settings.features.qwk_bbs_id' => 'QWK BBS ID', 'ui.admin.bbs_settings.features.qwk_bbs_id_help' => 'Bis zu 8 alphanumerische Zeichen als Paketkennung (BBSID). Eine Änderung kann bereits konfigurierte Offline-Reader dieser BBS unbrauchbar machen.', 'ui.admin.bbs_settings.validation.qwk_bbs_id_invalid' => 'QWK BBS ID muss sein 1–8 letters or digits.', @@ -2793,6 +2797,10 @@ 'ui.address_book.node_address_help' => 'Format: zone:net/node oder zone:net/node.point', 'ui.address_book.email_help' => 'Nur zu Deiner Referenz – wird nicht für Nachrichten verwendet', 'ui.address_book.description_placeholder' => 'Notes about this contact...', + 'ui.address_book.pgp_public_key' => 'PGP-Oeffentlicher Schluessel', + 'ui.address_book.pgp_public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.address_book.pgp_public_key_help' => 'Speichern Sie optional den oeffentlichen Schluessel dieses Kontakts fuer verschluesselte Antworten an entfernte Systeme.', + 'ui.address_book.pgp_key_linked' => 'PGP-Schluessel verknuepft', 'ui.address_book.always_crashmail' => 'Always use crashmail for this Empfänger', 'ui.address_book.always_crashmail_help' => 'Crashmail beim Verfassen von Nachrichten an diesen Kontakt automatisch aktivieren.', @@ -4959,4 +4967,91 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.settings.tab.pgp' => 'PGP', + 'ui.settings.pgp.heading' => 'PGP-Schluessel', + 'ui.settings.pgp.help' => 'Oeffentliche Schluessel hochladen, BBS-verwaltete private Schluessel erzeugen und den bevorzugten oeffentlichen Schluessel fuer den Keyserver auswaehlen.', + 'ui.settings.pgp.upload_heading' => 'Oeffentlichen Schluessel hochladen', + 'ui.settings.pgp.key_label' => 'Schluesselbezeichnung', + 'ui.settings.pgp.key_label_placeholder' => 'Laptop-Schluessel, Arbeitsschluessel, Archivschluessel', + 'ui.settings.pgp.public_key' => 'ASCII-armored oeffentlicher Schluessel', + 'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.settings.pgp.upload_button' => 'Oeffentlichen Schluessel hochladen', + 'ui.settings.pgp.generate_heading' => 'BBS-verwalteten Schluessel erzeugen', + 'ui.settings.pgp.managed_label_placeholder' => 'BinktermPHP verwalteter Schluessel', + 'ui.settings.pgp.passphrase' => 'PGP-Passphrase', + 'ui.settings.pgp.passphrase_placeholder' => 'PGP-Passphrase eingeben', + 'ui.settings.pgp.passphrase_help' => 'Verwenden Sie eine Passphrase, die sich von Ihrem Login-Passwort unterscheidet.', + 'ui.settings.pgp.passphrase_confirm' => 'Passphrase bestaetigen', + 'ui.settings.pgp.passphrase_confirm_placeholder' => 'PGP-Passphrase erneut eingeben', + 'ui.settings.pgp.generate_button' => 'Verwalteten Schluessel erzeugen', + 'ui.settings.pgp.generate_help' => 'Dieses erzeugt das Schluesselpaar im Browser und speichert nur den verschluesselten privaten Schluessel auf dem Server.', + 'ui.settings.pgp.managed_disabled_notice' => 'Diese BBS erlaubt das Hochladen oeffentlicher PGP-Schluessel, aber das Hosten BBS-verwalteter privater Schluessel ist vom Sysop deaktiviert.', + 'ui.settings.pgp.saved_keys_heading' => 'Gespeicherte Schluessel', + 'ui.settings.pgp.keyserver_button' => 'Keyserver durchsuchen', + 'ui.settings.pgp.no_keys' => 'Noch keine PGP-Schluessel gespeichert.', + 'ui.settings.pgp.col_primary' => 'Status', + 'ui.settings.pgp.col_label' => 'Bezeichnung', + 'ui.settings.pgp.col_fingerprint' => 'Fingerprint', + 'ui.settings.pgp.col_source' => 'Quelle', + 'ui.settings.pgp.col_created' => 'Erstellt', + 'ui.settings.pgp.col_actions' => 'Aktionen', + 'ui.settings.pgp.source_managed' => 'BBS verwaltet', + 'ui.settings.pgp.source_uploaded' => 'Hochgeladen', + 'ui.settings.pgp.private_key_available' => 'Verschluesselter privater Schluessel gespeichert', + 'ui.settings.pgp.primary_selector' => 'Bevorzugter oeffentlicher Schluessel', + 'ui.settings.pgp.primary_help' => 'Waehle hier einen Schluessel aus oder nutze die Schaltflaeche "Als primaer festlegen" in der Tabelle unten, um zu aendern, welcher oeffentliche Schluessel auf dem Keyserver bevorzugt angezeigt wird.', + 'ui.settings.pgp.primary_selected' => 'Primaer', + 'ui.settings.pgp.secondary_key' => 'Sekundaer', + 'ui.settings.pgp.make_primary_button' => 'Als primaer festlegen', + 'ui.settings.pgp.current_primary_button' => 'Aktuell primaer', + 'ui.settings.pgp.uploading' => 'Oeffentlichen Schluessel wird hochgeladen...', + 'ui.settings.pgp.upload_success' => 'PGP-Schluessel gespeichert.', + 'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js ist auf diesem BBS noch nicht installiert, daher ist die Browser-Schluesselerzeugung nicht verfuegbar.', + 'ui.settings.pgp.generating' => 'PGP-Schluesselpaar wird erzeugt...', + 'ui.settings.pgp.generate_success' => 'Verwalteter PGP-Schluessel wurde erzeugt und gespeichert.', + 'ui.settings.pgp.primary_updated' => 'Primaerer PGP-Schluessel aktualisiert.', + 'ui.settings.pgp.delete_confirm' => 'Diesen PGP-Schluessel loeschen?', + 'ui.settings.pgp.delete_success' => 'PGP-Schluessel geloescht.', + 'ui.settings.pgp.copy_success' => 'Fingerprint kopiert.', + 'ui.settings.pgp.copy_public_key_button' => 'Oeffentlichen Schluessel kopieren', + 'ui.settings.pgp.download_public_key_button' => 'Oeffentlichen Schluessel herunterladen', + 'ui.settings.pgp.copy_public_key_success' => 'Oeffentlicher Schluessel kopiert.', + 'ui.settings.pgp.key_details_title' => 'PGP-Schluesseldetails', + 'ui.settings.pgp.algorithm' => 'Algorithmus', + 'ui.compose.pgp.title' => 'PGP', + 'ui.compose.pgp.encrypt_netmail' => 'Dieses Netmail verschlüsseln', + 'ui.compose.pgp.encrypt_netmail_help' => 'Verschlüsselt die Nachricht für den Empfänger mit dessen veröffentlichtem öffentlichen Schlüssel.', + 'ui.compose.pgp.sign_echomail' => 'Dieses Echomail signieren', + 'ui.compose.pgp.sign_echomail_help' => 'Signiert die Nachricht mit Deinem gespeicherten verwalteten privaten Schlüssel.', + 'ui.compose.pgp.encrypt_only_notice' => 'Netmail-Verschluesselung braucht nur den oeffentlichen Schluessel des Empfaengers. Signieren und Entschluesseln erfordern Deinen verwalteten privaten Schluessel.', + 'ui.compose.pgp.encrypt_recipient_label' => 'Öffentlicher Schlüssel des Empfängers', + 'ui.compose.pgp.encrypt_recipient_help' => 'Wähle den öffentlichen Schlüssel, mit dem dieses Netmail verschlüsselt wird.', + 'ui.compose.pgp.encrypt_recipient_placeholder' => 'Öffentlichen Schlüssel zum Verschlüsseln auswählen', + 'ui.compose.pgp.encrypt_no_key' => 'Kein passender öffentlicher Schlüssel gefunden.', + 'ui.compose.pgp.encrypt_no_key_help' => 'Gib einen Empfänger mit veröffentlichtem öffentlichen Schlüssel an.', + 'ui.compose.pgp.encrypting_for' => 'Verschlüsselung für {recipient}', + 'ui.pgp.notice' => 'PGP-Aktionen verwenden Deinen gespeicherten verwalteten privaten Schlüssel. Wenn Du nur einen öffentlichen Schlüssel hochgeladen hast, sind Signieren und Entschlüsseln nicht verfügbar.', + 'ui.pgp.decrypt_button' => 'Entschlüsseln', + 'ui.pgp.decrypt_help' => 'Diese Nachricht ist mit PGP verschlüsselt.', + 'ui.pgp.passphrase_prompt' => 'Gib Deine PGP-Passphrase ein, um diese Nachricht zu entschlüsseln.', + 'ui.pgp.decrypted' => 'PGP entschlüsselt', + 'ui.pgp.verifying' => 'PGP-Signatur wird überprüft...', + 'ui.pgp.verified' => 'PGP-Signatur bestätigt', + 'ui.pgp.no_public_key' => 'Öffentlicher PGP-Schlüssel nicht gefunden', + 'ui.pgp.invalid' => 'Ungültige PGP-Signatur', + 'ui.keyserver.title' => 'PGP-Keyserver', + 'ui.keyserver.manage_keys' => 'Meine Schluessel verwalten', + 'ui.keyserver.search_label' => 'Suche', + 'ui.keyserver.search_placeholder' => 'Benutzername, Realname, E-Mail oder Fingerprint', + 'ui.keyserver.search_help' => 'Suchen Sie nach Kontoname, E-Mail-Adresse, vollstaendigem Fingerprint oder einer entfernten qualifizierten Suche wie awehttam@227:1/200 oder foobar@claudes.lovelybits.org.', + 'ui.keyserver.search_button' => 'Suchen', + 'ui.keyserver.results_heading' => 'Oeffentliche Schluessel', + 'ui.keyserver.col_user' => 'Benutzer', + 'ui.keyserver.col_fingerprint' => 'Fingerprint', + 'ui.keyserver.col_type' => 'Typ', + 'ui.keyserver.col_created' => 'Erstellt', + 'ui.keyserver.col_actions' => 'Aktionen', + 'ui.keyserver.primary_badge' => 'Primaer', + 'ui.keyserver.download_button' => 'Herunterladen', + 'ui.keyserver.no_results' => 'Keine oeffentlichen Schluessel entsprechen Ihrer Suche.', ]; diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index da59425f8..e79e1fbaf 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -86,6 +86,10 @@ 'errors.messages.send.invalid_type' => 'Ungültig: message type', 'errors.messages.send.failed' => 'Nachricht konnte nicht gesendet werden', 'errors.messages.send.exception' => 'Nachricht konnte nicht gesendet werden', + 'errors.pgp.decrypt_failed' => 'PGP-Nachricht konnte nicht entschlüsselt werden.', + 'errors.pgp.recipient_required' => 'Es konnte kein öffentlicher Schlüssel für den Empfänger ermittelt werden.', + 'errors.pgp.passphrase_required' => 'Bitte gib Deine PGP-Passphrase ein.', + 'errors.pgp.private_key_required' => 'Zum Signieren ist ein verwalteter privater Schluessel erforderlich.', // Notify 'errors.notify.user_id_missing' => 'Kann nicht aufgelöst werden: user session', @@ -722,4 +726,17 @@ 'errors.meshcore.not_found' => 'Kontakt nicht gefunden.', 'errors.meshcore.qr_unrecognized' => 'Unbekanntes QR-Code-Format.', 'errors.meshcore.qr_camera_denied' => 'Kamerazugriff verweigert.', + 'errors.pgp.load_failed' => 'PGP-Schluessel konnten nicht geladen werden.', + 'errors.pgp.public_key_required' => 'Ein oeffentlicher Schluessel ist erforderlich.', + 'errors.pgp.invalid_key' => 'Ungueltiger PGP-Schluessel.', + 'errors.pgp.save_failed' => 'PGP-Schluessel konnte nicht gespeichert werden.', + 'errors.pgp.invalid_keypair' => 'Ungueltiges PGP-Schluesselpaar.', + 'errors.pgp.key_not_found' => 'PGP-Schluessel nicht gefunden.', + 'errors.pgp.private_key_not_found' => 'Privater Schluessel nicht gefunden.', + 'errors.pgp.delete_failed' => 'PGP-Schluessel konnte nicht geloescht werden.', + 'errors.pgp.disabled' => 'PGP ist auf diesem System deaktiviert.', + 'errors.pgp.managed_disabled' => 'Die Erzeugung verwalteter PGP-Schluessel ist auf diesem System deaktiviert.', + 'errors.pgp.passphrase_too_short' => 'Verwenden Sie eine laengere PGP-Passphrase.', + 'errors.pgp.passphrase_mismatch' => 'Die Passphrase-Bestaetigung stimmt nicht ueberein.', + 'errors.pgp.generation_failed' => 'BBS-verwalteter PGP-Schluessel konnte nicht erzeugt werden.', ]; diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 913f1ba01..160977f73 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -1827,6 +1827,10 @@ 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Shows the public /bbs-directory page and BBS Lists nav menu. When disabled, the page returns 404.', 'ui.admin.bbs_settings.features.enable_qwk' => 'Enable QWK Offline Mail', 'ui.admin.bbs_settings.features.qwk_help' => 'Allows users to download QWK packets and upload REP reply packets for offline mail reading.', + 'ui.admin.bbs_settings.features.enable_pgp' => 'Enable PGP', + 'ui.admin.bbs_settings.features.pgp_help' => 'Enables user PGP key management, the public keyserver, and HKP-style public key lookup endpoints.', + 'ui.admin.bbs_settings.features.enable_pgp_managed_keys' => 'Allow BBS-managed private keys', + 'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'When enabled, users can generate browser-side PGP keypairs and store the encrypted private key blob on this server.', 'ui.admin.bbs_settings.features.qwk_bbs_id' => 'QWK BBS ID', 'ui.admin.bbs_settings.features.qwk_bbs_id_help' => 'Up to 8 alphanumeric characters used as the packet identifier (BBSID). Changing this will break existing offline readers that have already configured this BBS.', 'ui.admin.bbs_settings.validation.qwk_bbs_id_invalid' => 'QWK BBS ID must be 1–8 letters or digits.', @@ -2811,6 +2815,10 @@ 'ui.address_book.node_address_help' => 'Format: zone:net/node or zone:net/node.point', 'ui.address_book.email_help' => 'For your reference only - not used for messaging', 'ui.address_book.description_placeholder' => 'Notes about this contact...', + 'ui.address_book.pgp_public_key' => 'PGP public key', + 'ui.address_book.pgp_public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.address_book.pgp_public_key_help' => 'Optionally store this contact\'s public key for encrypted replies to remote systems.', + 'ui.address_book.pgp_key_linked' => 'PGP key linked', 'ui.address_book.always_crashmail' => 'Always use crashmail for this Recipient', 'ui.address_book.always_crashmail_help' => 'Automatically enable crashmail when composing messages to this contact.', @@ -4981,4 +4989,91 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.settings.tab.pgp' => 'PGP', + 'ui.settings.pgp.heading' => 'PGP Keys', + 'ui.settings.pgp.help' => 'Upload public keys, generate BBS-managed private keys, and choose which public key is your primary listing on the keyserver.', + 'ui.settings.pgp.upload_heading' => 'Upload Public Key', + 'ui.settings.pgp.key_label' => 'Key label', + 'ui.settings.pgp.key_label_placeholder' => 'Laptop key, work key, archive key', + 'ui.settings.pgp.public_key' => 'ASCII-armored public key', + 'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.settings.pgp.upload_button' => 'Upload Public Key', + 'ui.settings.pgp.generate_heading' => 'Generate BBS-Managed Key', + 'ui.settings.pgp.managed_label_placeholder' => 'BinktermPHP managed key', + 'ui.settings.pgp.passphrase' => 'PGP passphrase', + 'ui.settings.pgp.passphrase_placeholder' => 'Enter a PGP passphrase', + 'ui.settings.pgp.passphrase_help' => 'Use a passphrase that is different from your login password.', + 'ui.settings.pgp.passphrase_confirm' => 'Confirm passphrase', + 'ui.settings.pgp.passphrase_confirm_placeholder' => 'Re-enter your PGP passphrase', + 'ui.settings.pgp.generate_button' => 'Generate Managed Key', + 'ui.settings.pgp.generate_help' => 'This generates the keypair in your browser and stores only the encrypted private key blob on the server.', + 'ui.settings.pgp.managed_disabled_notice' => 'This BBS allows PGP public key uploads, but BBS-managed private key hosting is disabled by the sysop.', + 'ui.settings.pgp.saved_keys_heading' => 'Saved Keys', + 'ui.settings.pgp.keyserver_button' => 'Browse Keyserver', + 'ui.settings.pgp.no_keys' => 'No PGP keys saved yet.', + 'ui.settings.pgp.col_primary' => 'Status', + 'ui.settings.pgp.col_label' => 'Label', + 'ui.settings.pgp.col_fingerprint' => 'Fingerprint', + 'ui.settings.pgp.col_source' => 'Source', + 'ui.settings.pgp.col_created' => 'Created', + 'ui.settings.pgp.col_actions' => 'Actions', + 'ui.settings.pgp.source_managed' => 'BBS managed', + 'ui.settings.pgp.source_uploaded' => 'Uploaded', + 'ui.settings.pgp.private_key_available' => 'Encrypted private key stored', + 'ui.settings.pgp.primary_selector' => 'Preferred public key', + 'ui.settings.pgp.primary_help' => 'Pick a key here, or use the Make Primary button in the table below, to change which public key is listed as your preferred key on the keyserver.', + 'ui.settings.pgp.primary_selected' => 'Primary', + 'ui.settings.pgp.secondary_key' => 'Secondary', + 'ui.settings.pgp.make_primary_button' => 'Make Primary', + 'ui.settings.pgp.current_primary_button' => 'Current Primary', + 'ui.settings.pgp.uploading' => 'Uploading public key...', + 'ui.settings.pgp.upload_success' => 'PGP public key saved.', + 'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js is not installed on this BBS yet, so browser key generation is not available.', + 'ui.settings.pgp.generating' => 'Generating PGP keypair...', + 'ui.settings.pgp.generate_success' => 'Managed PGP key generated and saved.', + 'ui.settings.pgp.primary_updated' => 'Primary PGP key updated.', + 'ui.settings.pgp.delete_confirm' => 'Delete this PGP key?', + 'ui.settings.pgp.delete_success' => 'PGP key deleted.', + 'ui.settings.pgp.copy_success' => 'Fingerprint copied.', + 'ui.settings.pgp.copy_public_key_button' => 'Copy Public Key', + 'ui.settings.pgp.download_public_key_button' => 'Download Public Key', + 'ui.settings.pgp.copy_public_key_success' => 'Public key copied.', + 'ui.settings.pgp.key_details_title' => 'PGP key details', + 'ui.settings.pgp.algorithm' => 'Algorithm', + 'ui.compose.pgp.title' => 'PGP', + 'ui.compose.pgp.encrypt_netmail' => 'Encrypt this netmail', + 'ui.compose.pgp.encrypt_netmail_help' => 'Encrypts the message for the recipient using their published public key.', + 'ui.compose.pgp.sign_echomail' => 'Sign this echomail', + 'ui.compose.pgp.sign_echomail_help' => 'Signs the message with your stored managed private key.', + 'ui.compose.pgp.encrypt_only_notice' => 'Netmail encryption only needs the recipient public key. Signing and decrypting require your managed private key.', + 'ui.compose.pgp.encrypt_recipient_label' => 'Recipient public key', + 'ui.compose.pgp.encrypt_recipient_help' => 'Choose the public key that will be used to encrypt this netmail.', + 'ui.compose.pgp.encrypt_recipient_placeholder' => 'Select the public key to encrypt to', + 'ui.compose.pgp.encrypt_no_key' => 'No matching public key found.', + 'ui.compose.pgp.encrypt_no_key_help' => 'Enter a recipient that has a published public key.', + 'ui.compose.pgp.encrypting_for' => 'Encrypting for {recipient}', + 'ui.pgp.notice' => 'PGP actions use your stored managed private key. If you only uploaded a public key, signing and decrypting are unavailable.', + 'ui.pgp.decrypt_button' => 'Decrypt', + 'ui.pgp.decrypt_help' => 'This message is encrypted with PGP.', + 'ui.pgp.passphrase_prompt' => 'Enter your PGP passphrase to decrypt this message.', + 'ui.pgp.decrypted' => 'PGP decrypted', + 'ui.pgp.verifying' => 'Verifying PGP signature...', + 'ui.pgp.verified' => 'PGP signature verified', + 'ui.pgp.no_public_key' => 'PGP public key not found', + 'ui.pgp.invalid' => 'Invalid PGP signature', + 'ui.keyserver.title' => 'PGP Keyserver', + 'ui.keyserver.manage_keys' => 'Manage My Keys', + 'ui.keyserver.search_label' => 'Search', + 'ui.keyserver.search_placeholder' => 'Username, real name, email, or fingerprint', + 'ui.keyserver.search_help' => 'Search by account name, email address, full fingerprint, or a remote-qualified lookup such as awehttam@227:1/200 or foobar@claudes.lovelybits.org.', + 'ui.keyserver.search_button' => 'Search', + 'ui.keyserver.results_heading' => 'Public Keys', + 'ui.keyserver.col_user' => 'User', + 'ui.keyserver.col_fingerprint' => 'Fingerprint', + 'ui.keyserver.col_type' => 'Type', + 'ui.keyserver.col_created' => 'Created', + 'ui.keyserver.col_actions' => 'Actions', + 'ui.keyserver.primary_badge' => 'Primary', + 'ui.keyserver.download_button' => 'Download', + 'ui.keyserver.no_results' => 'No public keys matched your search.', ]; diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index bd758a978..73bc03e4f 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -86,6 +86,10 @@ 'errors.messages.send.invalid_type' => 'Invalid message type', 'errors.messages.send.failed' => 'Failed to send message', 'errors.messages.send.exception' => 'Failed to send message', + 'errors.pgp.decrypt_failed' => 'Failed to decrypt PGP message.', + 'errors.pgp.recipient_required' => 'A recipient public key could not be resolved.', + 'errors.pgp.passphrase_required' => 'Enter your PGP passphrase.', + 'errors.pgp.private_key_required' => 'A managed private key is required for signing.', // Notify 'errors.notify.user_id_missing' => 'Unable to resolve user session', @@ -722,4 +726,17 @@ 'errors.meshcore.not_found' => 'Contact not found.', 'errors.meshcore.qr_unrecognized' => 'Unrecognized QR code format.', 'errors.meshcore.qr_camera_denied' => 'Camera access denied.', + 'errors.pgp.load_failed' => 'Failed to load PGP keys.', + 'errors.pgp.public_key_required' => 'A public key is required.', + 'errors.pgp.invalid_key' => 'Invalid PGP public key.', + 'errors.pgp.save_failed' => 'Failed to save PGP key.', + 'errors.pgp.invalid_keypair' => 'Invalid PGP keypair.', + 'errors.pgp.key_not_found' => 'PGP key not found.', + 'errors.pgp.private_key_not_found' => 'Private key not found.', + 'errors.pgp.delete_failed' => 'Failed to delete PGP key.', + 'errors.pgp.disabled' => 'PGP is disabled on this system.', + 'errors.pgp.managed_disabled' => 'Managed PGP key generation is disabled on this system.', + 'errors.pgp.passphrase_too_short' => 'Use a longer PGP passphrase.', + 'errors.pgp.passphrase_mismatch' => 'Passphrase confirmation does not match.', + 'errors.pgp.generation_failed' => 'Failed to generate managed PGP key.', ]; diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index ebd0a95e1..46ec7ba52 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -1861,6 +1861,10 @@ 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Muestra la pagina publica /bbs-directory y el menu de navegacion de listas BBS. Cuando esta deshabilitado, la pagina devuelve 404.', 'ui.admin.bbs_settings.features.enable_qwk' => 'Habilitar correo sin conexion QWK', 'ui.admin.bbs_settings.features.qwk_help' => 'Permite a los usuarios descargar paquetes QWK y subir paquetes de respuesta REP para leer correo sin conexion.', + 'ui.admin.bbs_settings.features.enable_pgp' => 'Habilitar PGP', + 'ui.admin.bbs_settings.features.pgp_help' => 'Activa la gestion de claves PGP de los usuarios, el keyserver publico y los endpoints de busqueda de claves publicas estilo HKP.', + 'ui.admin.bbs_settings.features.enable_pgp_managed_keys' => 'Permitir claves privadas gestionadas por el BBS', + 'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'Cuando esta activado, los usuarios pueden generar pares de claves PGP en el navegador y guardar en este servidor el blob cifrado de la clave privada.', 'ui.admin.bbs_settings.features.qwk_bbs_id' => 'ID de BBS QWK', 'ui.admin.bbs_settings.features.qwk_bbs_id_help' => 'Hasta 8 caracteres alfanumericos usados como identificador del paquete (BBSID). Cambiarlo romperá los lectores sin conexion que ya tienen configurado este BBS.', 'ui.admin.bbs_settings.validation.qwk_bbs_id_invalid' => 'El ID de BBS QWK debe tener entre 1 y 8 letras o digitos.', @@ -2794,6 +2798,10 @@ 'ui.address_book.node_address_help' => 'Formato: zona:red/nodo o zona:red/nodo.punto', 'ui.address_book.email_help' => 'Solo para referencia; no se usa para mensajeria', 'ui.address_book.description_placeholder' => 'Notas sobre este contacto...', + 'ui.address_book.pgp_public_key' => 'Clave publica PGP', + 'ui.address_book.pgp_public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.address_book.pgp_public_key_help' => 'Guarda opcionalmente la clave publica de este contacto para respuestas cifradas a sistemas remotos.', + 'ui.address_book.pgp_key_linked' => 'Clave PGP vinculada', 'ui.address_book.always_crashmail' => 'Usar siempre crashmail para este destinatario', 'ui.address_book.always_crashmail_help' => 'Activa crashmail automaticamente al redactar mensajes para este contacto.', @@ -4947,4 +4955,91 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.settings.tab.pgp' => 'PGP', + 'ui.settings.pgp.heading' => 'Claves PGP', + 'ui.settings.pgp.help' => 'Suba claves publicas, genere claves privadas administradas por el BBS y elija cual clave publica sera la principal en el servidor de claves.', + 'ui.settings.pgp.upload_heading' => 'Subir clave publica', + 'ui.settings.pgp.key_label' => 'Etiqueta de la clave', + 'ui.settings.pgp.key_label_placeholder' => 'Clave del portatil, del trabajo, de archivo', + 'ui.settings.pgp.public_key' => 'Clave publica ASCII-armored', + 'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.settings.pgp.upload_button' => 'Subir clave publica', + 'ui.settings.pgp.generate_heading' => 'Generar clave administrada por el BBS', + 'ui.settings.pgp.managed_label_placeholder' => 'Clave administrada por BinktermPHP', + 'ui.settings.pgp.passphrase' => 'Frase de acceso PGP', + 'ui.settings.pgp.passphrase_placeholder' => 'Introduzca una frase de acceso PGP', + 'ui.settings.pgp.passphrase_help' => 'Use una frase de acceso diferente de su contrasena de inicio de sesion.', + 'ui.settings.pgp.passphrase_confirm' => 'Confirmar frase de acceso', + 'ui.settings.pgp.passphrase_confirm_placeholder' => 'Vuelva a escribir su frase de acceso PGP', + 'ui.settings.pgp.generate_button' => 'Generar clave administrada', + 'ui.settings.pgp.generate_help' => 'Esto genera el par de claves en su navegador y solo guarda el blob cifrado de la clave privada en el servidor.', + 'ui.settings.pgp.managed_disabled_notice' => 'Este BBS permite subir claves publicas PGP, pero el alojamiento de claves privadas gestionadas por el BBS esta desactivado por el sysop.', + 'ui.settings.pgp.saved_keys_heading' => 'Claves guardadas', + 'ui.settings.pgp.keyserver_button' => 'Explorar servidor de claves', + 'ui.settings.pgp.no_keys' => 'Todavia no hay claves PGP guardadas.', + 'ui.settings.pgp.col_primary' => 'Estado', + 'ui.settings.pgp.col_label' => 'Etiqueta', + 'ui.settings.pgp.col_fingerprint' => 'Huella', + 'ui.settings.pgp.col_source' => 'Origen', + 'ui.settings.pgp.col_created' => 'Creada', + 'ui.settings.pgp.col_actions' => 'Acciones', + 'ui.settings.pgp.source_managed' => 'Administrada por el BBS', + 'ui.settings.pgp.source_uploaded' => 'Subida', + 'ui.settings.pgp.private_key_available' => 'Clave privada cifrada almacenada', + 'ui.settings.pgp.primary_selector' => 'Clave publica preferida', + 'ui.settings.pgp.primary_help' => 'Elige aqui una clave, o usa el boton de la tabla de abajo para marcarla como principal, y asi cambiar que clave publica aparece como preferida en el servidor de claves.', + 'ui.settings.pgp.primary_selected' => 'Principal', + 'ui.settings.pgp.secondary_key' => 'Secundaria', + 'ui.settings.pgp.make_primary_button' => 'Marcar principal', + 'ui.settings.pgp.current_primary_button' => 'Principal actual', + 'ui.settings.pgp.uploading' => 'Subiendo clave publica...', + 'ui.settings.pgp.upload_success' => 'Clave PGP guardada.', + 'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js aun no esta instalado en este BBS, por lo que la generacion en el navegador no esta disponible.', + 'ui.settings.pgp.generating' => 'Generando par de claves PGP...', + 'ui.settings.pgp.generate_success' => 'Clave PGP administrada generada y guardada.', + 'ui.settings.pgp.primary_updated' => 'Clave PGP principal actualizada.', + 'ui.settings.pgp.delete_confirm' => 'Eliminar esta clave PGP?', + 'ui.settings.pgp.delete_success' => 'Clave PGP eliminada.', + 'ui.settings.pgp.copy_success' => 'Huella copiada.', + 'ui.settings.pgp.copy_public_key_button' => 'Copiar clave publica', + 'ui.settings.pgp.download_public_key_button' => 'Descargar clave publica', + 'ui.settings.pgp.copy_public_key_success' => 'Clave publica copiada.', + 'ui.settings.pgp.key_details_title' => 'Detalles de la clave PGP', + 'ui.settings.pgp.algorithm' => 'Algoritmo', + 'ui.compose.pgp.title' => 'PGP', + 'ui.compose.pgp.encrypt_netmail' => 'Cifrar este netmail', + 'ui.compose.pgp.encrypt_netmail_help' => 'Cifra el mensaje para el destinatario usando su clave pública publicada.', + 'ui.compose.pgp.sign_echomail' => 'Firmar este echomail', + 'ui.compose.pgp.sign_echomail_help' => 'Firma el mensaje con tu clave privada administrada almacenada.', + 'ui.compose.pgp.encrypt_only_notice' => 'El cifrado de netmail solo necesita la clave publica del destinatario. Firmar y descifrar requieren tu clave privada administrada.', + 'ui.compose.pgp.encrypt_recipient_label' => 'Clave pública del destinatario', + 'ui.compose.pgp.encrypt_recipient_help' => 'Elige la clave pública que se usará para cifrar este netmail.', + 'ui.compose.pgp.encrypt_recipient_placeholder' => 'Seleccione la clave pública para cifrar', + 'ui.compose.pgp.encrypt_no_key' => 'No se encontró una clave pública coincidente.', + 'ui.compose.pgp.encrypt_no_key_help' => 'Introduzca un destinatario que tenga una clave pública publicada.', + 'ui.compose.pgp.encrypting_for' => 'Cifrando para {recipient}', + 'ui.pgp.notice' => 'Las acciones PGP usan tu clave privada administrada almacenada. Si solo subiste una clave pública, no se puede firmar ni descifrar.', + 'ui.pgp.decrypt_button' => 'Descifrar', + 'ui.pgp.decrypt_help' => 'Este mensaje está cifrado con PGP.', + 'ui.pgp.passphrase_prompt' => 'Introduce tu frase de acceso PGP para descifrar este mensaje.', + 'ui.pgp.decrypted' => 'PGP descifrado', + 'ui.pgp.verifying' => 'Verificando firma PGP...', + 'ui.pgp.verified' => 'Firma PGP verificada', + 'ui.pgp.no_public_key' => 'No se encontró la clave pública PGP', + 'ui.pgp.invalid' => 'Firma PGP inválida', + 'ui.keyserver.title' => 'Servidor de claves PGP', + 'ui.keyserver.manage_keys' => 'Administrar mis claves', + 'ui.keyserver.search_label' => 'Buscar', + 'ui.keyserver.search_placeholder' => 'Nombre de usuario, nombre real, correo o huella', + 'ui.keyserver.search_help' => 'Busque por nombre de cuenta, direccion de correo, huella completa o una busqueda remota calificada como awehttam@227:1/200 o foobar@claudes.lovelybits.org.', + 'ui.keyserver.search_button' => 'Buscar', + 'ui.keyserver.results_heading' => 'Claves publicas', + 'ui.keyserver.col_user' => 'Usuario', + 'ui.keyserver.col_fingerprint' => 'Huella', + 'ui.keyserver.col_type' => 'Tipo', + 'ui.keyserver.col_created' => 'Creada', + 'ui.keyserver.col_actions' => 'Acciones', + 'ui.keyserver.primary_badge' => 'Principal', + 'ui.keyserver.download_button' => 'Descargar', + 'ui.keyserver.no_results' => 'Ninguna clave publica coincide con su busqueda.', ]; diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index ec54a6da7..b1427b86b 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -86,6 +86,10 @@ 'errors.messages.send.invalid_type' => 'Tipo de mensaje invalido', 'errors.messages.send.failed' => 'No se pudo enviar el mensaje', 'errors.messages.send.exception' => 'No se pudo enviar el mensaje', + 'errors.pgp.decrypt_failed' => 'No se pudo descifrar el mensaje PGP.', + 'errors.pgp.recipient_required' => 'No se pudo resolver una clave pública para el destinatario.', + 'errors.pgp.passphrase_required' => 'Introduce tu frase de acceso PGP.', + 'errors.pgp.private_key_required' => 'Se requiere una clave privada administrada para firmar.', // Notify 'errors.notify.user_id_missing' => 'No se pudo resolver la sesion del usuario', @@ -720,4 +724,17 @@ 'errors.meshcore.not_found' => 'Contacto no encontrado.', 'errors.meshcore.qr_unrecognized' => 'Formato de código QR no reconocido.', 'errors.meshcore.qr_camera_denied' => 'Acceso a la cámara denegado.', + 'errors.pgp.load_failed' => 'No se pudieron cargar las claves PGP.', + 'errors.pgp.public_key_required' => 'Se requiere una clave publica.', + 'errors.pgp.invalid_key' => 'Clave publica PGP invalida.', + 'errors.pgp.save_failed' => 'No se pudo guardar la clave PGP.', + 'errors.pgp.invalid_keypair' => 'Par de claves PGP invalido.', + 'errors.pgp.key_not_found' => 'No se encontro la clave PGP.', + 'errors.pgp.private_key_not_found' => 'No se encontro la clave privada.', + 'errors.pgp.delete_failed' => 'No se pudo eliminar la clave PGP.', + 'errors.pgp.disabled' => 'PGP esta desactivado en este sistema.', + 'errors.pgp.managed_disabled' => 'La generacion de claves PGP gestionadas esta desactivada en este sistema.', + 'errors.pgp.passphrase_too_short' => 'Use una frase de acceso PGP mas larga.', + 'errors.pgp.passphrase_mismatch' => 'La confirmacion de la frase de acceso no coincide.', + 'errors.pgp.generation_failed' => 'No se pudo generar la clave PGP administrada.', ]; diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index 822aa669a..203fd386e 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -2329,6 +2329,10 @@ 'ui.address_book.node_address_help' => 'Format : zone:net/node ou zone:net/node.point', 'ui.address_book.email_help' => 'Pour votre référence uniquement - non utilisé pour la messagerie', 'ui.address_book.description_placeholder' => 'Notes sur ce contact...', + 'ui.address_book.pgp_public_key' => 'Cle publique PGP', + 'ui.address_book.pgp_public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.address_book.pgp_public_key_help' => 'Enregistrez facultativement la cle publique de ce contact pour les reponses chiffrees vers des systemes distants.', + 'ui.address_book.pgp_key_linked' => 'Cle PGP liee', 'ui.address_book.always_crashmail' => 'Toujours utiliser le crashmail pour ce destinataire', 'ui.address_book.always_crashmail_help' => 'Activer automatiquement le crashmail lors de la rédaction de messages à ce contact.', 'ui.echomail.title' => 'Echomail', @@ -3881,6 +3885,10 @@ 'ui.admin.bbs_settings.features.qwk_bbs_id' => 'ID BBS QWK', 'ui.admin.bbs_settings.features.qwk_bbs_id_help' => 'Jusqu\'à 8 caractères alphanumériques utilisés comme identifiant du paquet (BBSID). Le modifier cassera les lecteurs hors ligne déjà configurés pour ce BBS.', 'ui.admin.bbs_settings.validation.qwk_bbs_id_invalid' => 'L\'ID BBS QWK doit comporter entre 1 et 8 lettres ou chiffres.', + 'ui.admin.bbs_settings.features.enable_pgp' => 'Activer PGP', + 'ui.admin.bbs_settings.features.pgp_help' => 'Active la gestion des cles PGP des utilisateurs, le serveur de cles public et les points de recherche de cles publiques de style HKP.', + 'ui.admin.bbs_settings.features.enable_pgp_managed_keys' => 'Autoriser les cles privees gerees par le BBS', + 'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'Quand cette option est activee, les utilisateurs peuvent generer des paires de cles PGP dans le navigateur et stocker sur ce serveur le blob chiffre de la cle privee.', 'ui.admin.bbs_settings.features.public_files_index_help' => 'Affiche une page publique /public-files listant toutes les zones de fichiers publiques. Ajoute également un lien de navigation pour les visiteurs. Nécessite une licence enregistrée.', 'ui.admin.bbs_settings.features.public_files_index_requires_license' => 'L\'index des zones de fichiers publiques nécessite une licence enregistrée.', 'ui.admin.bbs_settings.features.qwk_help' => 'Permet aux utilisateurs de télécharger des paquets QWK et d\'envoyer des paquets de réponses REP pour la lecture de courrier hors ligne.', @@ -4886,4 +4894,91 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.settings.tab.pgp' => 'PGP', + 'ui.settings.pgp.heading' => 'Cles PGP', + 'ui.settings.pgp.help' => 'Televersez des cles publiques, generez des cles privees gerees par le BBS et choisissez la cle publique principale a publier sur le serveur de cles.', + 'ui.settings.pgp.upload_heading' => 'Televerser une cle publique', + 'ui.settings.pgp.key_label' => 'Etiquette de la cle', + 'ui.settings.pgp.key_label_placeholder' => 'Cle portable, travail, archive', + 'ui.settings.pgp.public_key' => 'Cle publique ASCII-armored', + 'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.settings.pgp.upload_button' => 'Televerser la cle publique', + 'ui.settings.pgp.generate_heading' => 'Generer une cle geree par le BBS', + 'ui.settings.pgp.managed_label_placeholder' => 'Cle geree par BinktermPHP', + 'ui.settings.pgp.passphrase' => 'Phrase de passe PGP', + 'ui.settings.pgp.passphrase_placeholder' => 'Saisissez une phrase de passe PGP', + 'ui.settings.pgp.passphrase_help' => 'Utilisez une phrase de passe differente de votre mot de passe de connexion.', + 'ui.settings.pgp.passphrase_confirm' => 'Confirmer la phrase de passe', + 'ui.settings.pgp.passphrase_confirm_placeholder' => 'Saisissez de nouveau votre phrase de passe PGP', + 'ui.settings.pgp.generate_button' => 'Generer la cle geree', + 'ui.settings.pgp.generate_help' => 'Cela genere la paire de cles dans le navigateur et seul le blob chiffre de la cle privee est stocke sur le serveur.', + 'ui.settings.pgp.managed_disabled_notice' => 'Ce BBS autorise le televersement de cles publiques PGP, mais l\'hebergement de cles privees gerees par le BBS est desactive par le sysop.', + 'ui.settings.pgp.saved_keys_heading' => 'Cles enregistrees', + 'ui.settings.pgp.keyserver_button' => 'Parcourir le serveur de cles', + 'ui.settings.pgp.no_keys' => 'Aucune cle PGP enregistree pour le moment.', + 'ui.settings.pgp.col_primary' => 'Statut', + 'ui.settings.pgp.col_label' => 'Etiquette', + 'ui.settings.pgp.col_fingerprint' => 'Empreinte', + 'ui.settings.pgp.col_source' => 'Source', + 'ui.settings.pgp.col_created' => 'Creation', + 'ui.settings.pgp.col_actions' => 'Actions', + 'ui.settings.pgp.source_managed' => 'Geree par le BBS', + 'ui.settings.pgp.source_uploaded' => 'Televersee', + 'ui.settings.pgp.private_key_available' => 'Cle privee chiffree stockee', + 'ui.settings.pgp.primary_selector' => 'Cle publique preferee', + 'ui.settings.pgp.primary_help' => 'Choisissez ici une cle, ou utilisez le bouton du tableau ci-dessous pour la definir comme principale, afin de changer la cle publique affichee comme preferee sur le serveur de cles.', + 'ui.settings.pgp.primary_selected' => 'Principale', + 'ui.settings.pgp.secondary_key' => 'Secondaire', + 'ui.settings.pgp.make_primary_button' => 'Definir principale', + 'ui.settings.pgp.current_primary_button' => 'Principale actuelle', + 'ui.settings.pgp.uploading' => 'Televersement de la cle publique...', + 'ui.settings.pgp.upload_success' => 'Cle PGP enregistree.', + 'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js n\'est pas encore installe sur ce BBS, donc la generation de cles dans le navigateur n\'est pas disponible.', + 'ui.settings.pgp.generating' => 'Generation de la paire de cles PGP...', + 'ui.settings.pgp.generate_success' => 'Cle PGP geree generee et enregistree.', + 'ui.settings.pgp.primary_updated' => 'Cle PGP principale mise a jour.', + 'ui.settings.pgp.delete_confirm' => 'Supprimer cette cle PGP ?', + 'ui.settings.pgp.delete_success' => 'Cle PGP supprimee.', + 'ui.settings.pgp.copy_success' => 'Empreinte copiee.', + 'ui.settings.pgp.copy_public_key_button' => 'Copier la cle publique', + 'ui.settings.pgp.download_public_key_button' => 'Telecharger la cle publique', + 'ui.settings.pgp.copy_public_key_success' => 'Cle publique copiee.', + 'ui.settings.pgp.key_details_title' => 'Détails de la clé PGP', + 'ui.settings.pgp.algorithm' => 'Algorithme', + 'ui.compose.pgp.title' => 'PGP', + 'ui.compose.pgp.encrypt_netmail' => 'Chiffrer ce netmail', + 'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a l’aide de sa cle publique publiee.', + 'ui.compose.pgp.sign_echomail' => 'Signer cet echomail', + 'ui.compose.pgp.sign_echomail_help' => 'Signe le message avec votre cle privee geree stockee.', + 'ui.compose.pgp.encrypt_only_notice' => 'Le chiffrement netmail a seulement besoin de la cle publique du destinataire. La signature et le dechiffrement exigent votre cle privee geree.', + 'ui.compose.pgp.encrypt_recipient_label' => 'Cle publique du destinataire', + 'ui.compose.pgp.encrypt_recipient_help' => 'Choisissez la cle publique qui sera utilisee pour chiffrer ce netmail.', + 'ui.compose.pgp.encrypt_recipient_placeholder' => 'Selectionnez la cle publique pour le chiffrement', + 'ui.compose.pgp.encrypt_no_key' => 'Aucune cle publique correspondante trouvee.', + 'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant d’une cle publique publiee.', + 'ui.compose.pgp.encrypting_for' => 'Chiffrement pour {recipient}', + 'ui.pgp.notice' => 'Les actions PGP utilisent votre cle privee geree stockee. Si vous avez seulement televerse une cle publique, la signature et le dechiffrement ne sont pas disponibles.', + 'ui.pgp.decrypt_button' => 'Dechiffrer', + 'ui.pgp.decrypt_help' => 'Ce message est chiffre avec PGP.', + 'ui.pgp.passphrase_prompt' => 'Saisissez votre phrase de passe PGP pour dechiffrer ce message.', + 'ui.pgp.decrypted' => 'PGP dechiffre', + 'ui.pgp.verifying' => 'Verification de la signature PGP...', + 'ui.pgp.verified' => 'Signature PGP verifiee', + 'ui.pgp.no_public_key' => 'Cle publique PGP introuvable', + 'ui.pgp.invalid' => 'Signature PGP invalide', + 'ui.keyserver.title' => 'Serveur de cles PGP', + 'ui.keyserver.manage_keys' => 'Gerer mes cles', + 'ui.keyserver.search_label' => 'Recherche', + 'ui.keyserver.search_placeholder' => 'Nom d\'utilisateur, nom reel, courriel ou empreinte', + 'ui.keyserver.search_help' => 'Recherchez par nom de compte, adresse courriel, empreinte complete ou recherche distante qualifiee comme awehttam@227:1/200 ou foobar@claudes.lovelybits.org.', + 'ui.keyserver.search_button' => 'Rechercher', + 'ui.keyserver.results_heading' => 'Cles publiques', + 'ui.keyserver.col_user' => 'Utilisateur', + 'ui.keyserver.col_fingerprint' => 'Empreinte', + 'ui.keyserver.col_type' => 'Type', + 'ui.keyserver.col_created' => 'Creation', + 'ui.keyserver.col_actions' => 'Actions', + 'ui.keyserver.primary_badge' => 'Principale', + 'ui.keyserver.download_button' => 'Telecharger', + 'ui.keyserver.no_results' => 'Aucune cle publique ne correspond a votre recherche.', ]; diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index a72f20ba5..f7657f47c 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -62,6 +62,10 @@ 'errors.messages.send.invalid_type' => 'Type de message invalide', 'errors.messages.send.failed' => 'Échec de l\'envoi du message', 'errors.messages.send.exception' => 'Échec de l\'envoi du message', + 'errors.pgp.decrypt_failed' => 'Impossible de dechiffrer le message PGP.', + 'errors.pgp.recipient_required' => 'Impossible de resoudre une cle publique pour le destinataire.', + 'errors.pgp.passphrase_required' => 'Saisissez votre phrase de passe PGP.', + 'errors.pgp.private_key_required' => 'Une cle privee geree est requise pour signer.', 'errors.notify.user_id_missing' => 'Impossible de résoudre la session utilisateur', 'errors.notify.invalid_state' => 'Charge utile d\'état de notification invalide', 'errors.notify.invalid_target' => 'Cible de notification invalide', @@ -679,6 +683,19 @@ 'errors.meshcore.not_found' => 'Contact introuvable.', 'errors.meshcore.qr_unrecognized' => 'Format de QR code non reconnu.', 'errors.meshcore.qr_camera_denied' => 'Accès à la caméra refusé.', + 'errors.pgp.load_failed' => 'Impossible de charger les cles PGP.', + 'errors.pgp.public_key_required' => 'Une cle publique est requise.', + 'errors.pgp.invalid_key' => 'Cle publique PGP invalide.', + 'errors.pgp.save_failed' => 'Impossible d\'enregistrer la cle PGP.', + 'errors.pgp.invalid_keypair' => 'Paire de cles PGP invalide.', + 'errors.pgp.key_not_found' => 'Cle PGP introuvable.', + 'errors.pgp.private_key_not_found' => 'Cle privee introuvable.', + 'errors.pgp.delete_failed' => 'Impossible de supprimer la cle PGP.', + 'errors.pgp.disabled' => 'PGP est desactive sur ce systeme.', + 'errors.pgp.managed_disabled' => 'La generation de cles PGP gerees est desactivee sur ce systeme.', + 'errors.pgp.passphrase_too_short' => 'Utilisez une phrase de passe PGP plus longue.', + 'errors.pgp.passphrase_mismatch' => 'La confirmation de la phrase de passe ne correspond pas.', + 'errors.pgp.generation_failed' => 'Impossible de generer la cle PGP geree.', ]; diff --git a/config/i18n/it/common.php b/config/i18n/it/common.php index ba37f1a7f..99717674f 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -1808,6 +1808,10 @@ 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Mostra la pagina pubblica /bbs-directory e il menu di navigazione Liste BBS. Se disabilitata, la pagina restituisce 404.', 'ui.admin.bbs_settings.features.enable_qwk' => 'Abilita posta offline QWK', 'ui.admin.bbs_settings.features.qwk_help' => 'Consente agli utenti di scaricare pacchetti QWK e caricare pacchetti risposta REP per leggere la posta offline.', + 'ui.admin.bbs_settings.features.enable_pgp' => 'Abilita PGP', + 'ui.admin.bbs_settings.features.pgp_help' => 'Abilita la gestione delle chiavi PGP degli utenti, il keyserver pubblico e gli endpoint di ricerca delle chiavi pubbliche in stile HKP.', + 'ui.admin.bbs_settings.features.enable_pgp_managed_keys' => 'Consenti chiavi private gestite dal BBS', + 'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'Quando e abilitato, gli utenti possono generare coppie di chiavi PGP nel browser e salvare su questo server il blob cifrato della chiave privata.', 'ui.admin.bbs_settings.features.qwk_bbs_id' => 'ID BBS QWK', 'ui.admin.bbs_settings.features.qwk_bbs_id_help' => 'Fino a 8 caratteri alfanumerici usati come identificatore pacchetto (BBSID). Modificarlo interromperà i lettori offline già configurati per questa BBS.', 'ui.admin.bbs_settings.validation.qwk_bbs_id_invalid' => 'L’ID BBS QWK deve contenere 1–8 lettere o cifre.', @@ -2793,6 +2797,10 @@ 'ui.address_book.node_address_help' => 'Formato: zona:net/nodo o zona:net/nodo.punto', 'ui.address_book.email_help' => 'Solo per riferimento - non usato per la messaggistica', 'ui.address_book.description_placeholder' => 'Note su questo contatto...', + 'ui.address_book.pgp_public_key' => 'Chiave pubblica PGP', + 'ui.address_book.pgp_public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.address_book.pgp_public_key_help' => 'Salva facoltativamente la chiave pubblica di questo contatto per le risposte cifrate verso sistemi remoti.', + 'ui.address_book.pgp_key_linked' => 'Chiave PGP collegata', 'ui.address_book.always_crashmail' => 'Usa sempre crashmail per questo destinatario', 'ui.address_book.always_crashmail_help' => 'Abilita automaticamente crashmail quando componi messaggi per questo contatto.', @@ -4944,4 +4952,91 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.settings.tab.pgp' => 'PGP', + 'ui.settings.pgp.heading' => 'Chiavi PGP', + 'ui.settings.pgp.help' => 'Carica chiavi pubbliche, genera chiavi private gestite dal BBS e scegli quale chiave pubblica deve essere la principale sul keyserver.', + 'ui.settings.pgp.upload_heading' => 'Carica chiave pubblica', + 'ui.settings.pgp.key_label' => 'Etichetta chiave', + 'ui.settings.pgp.key_label_placeholder' => 'Chiave portatile, lavoro, archivio', + 'ui.settings.pgp.public_key' => 'Chiave pubblica ASCII-armored', + 'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----', + 'ui.settings.pgp.upload_button' => 'Carica chiave pubblica', + 'ui.settings.pgp.generate_heading' => 'Genera chiave gestita dal BBS', + 'ui.settings.pgp.managed_label_placeholder' => 'Chiave gestita da BinktermPHP', + 'ui.settings.pgp.passphrase' => 'Passphrase PGP', + 'ui.settings.pgp.passphrase_placeholder' => 'Inserisci una passphrase PGP', + 'ui.settings.pgp.passphrase_help' => 'Usa una passphrase diversa dalla password di accesso.', + 'ui.settings.pgp.passphrase_confirm' => 'Conferma passphrase', + 'ui.settings.pgp.passphrase_confirm_placeholder' => 'Inserisci di nuovo la passphrase PGP', + 'ui.settings.pgp.generate_button' => 'Genera chiave gestita', + 'ui.settings.pgp.generate_help' => 'Questo genera la coppia di chiavi nel browser e sul server viene salvato solo il blob cifrato della chiave privata.', + 'ui.settings.pgp.managed_disabled_notice' => 'Questo BBS consente il caricamento di chiavi pubbliche PGP, ma l\'hosting di chiavi private gestite dal BBS e disabilitato dal sysop.', + 'ui.settings.pgp.saved_keys_heading' => 'Chiavi salvate', + 'ui.settings.pgp.keyserver_button' => 'Sfoglia keyserver', + 'ui.settings.pgp.no_keys' => 'Nessuna chiave PGP salvata.', + 'ui.settings.pgp.col_primary' => 'Stato', + 'ui.settings.pgp.col_label' => 'Etichetta', + 'ui.settings.pgp.col_fingerprint' => 'Fingerprint', + 'ui.settings.pgp.col_source' => 'Origine', + 'ui.settings.pgp.col_created' => 'Creata', + 'ui.settings.pgp.col_actions' => 'Azioni', + 'ui.settings.pgp.source_managed' => 'Gestita dal BBS', + 'ui.settings.pgp.source_uploaded' => 'Caricata', + 'ui.settings.pgp.private_key_available' => 'Chiave privata cifrata memorizzata', + 'ui.settings.pgp.primary_selector' => 'Chiave pubblica preferita', + 'ui.settings.pgp.primary_help' => 'Scegli qui una chiave, oppure usa il pulsante nella tabella sotto per impostarla come principale, cosi cambi quale chiave pubblica viene mostrata come preferita sul keyserver.', + 'ui.settings.pgp.primary_selected' => 'Principale', + 'ui.settings.pgp.secondary_key' => 'Secondaria', + 'ui.settings.pgp.make_primary_button' => 'Imposta principale', + 'ui.settings.pgp.current_primary_button' => 'Principale attuale', + 'ui.settings.pgp.uploading' => 'Caricamento chiave pubblica...', + 'ui.settings.pgp.upload_success' => 'Chiave PGP salvata.', + 'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js non e ancora installato su questo BBS, quindi la generazione nel browser non e disponibile.', + 'ui.settings.pgp.generating' => 'Generazione della coppia di chiavi PGP...', + 'ui.settings.pgp.generate_success' => 'Chiave PGP gestita generata e salvata.', + 'ui.settings.pgp.primary_updated' => 'Chiave PGP principale aggiornata.', + 'ui.settings.pgp.delete_confirm' => 'Eliminare questa chiave PGP?', + 'ui.settings.pgp.delete_success' => 'Chiave PGP eliminata.', + 'ui.settings.pgp.copy_success' => 'Fingerprint copiato.', + 'ui.settings.pgp.copy_public_key_button' => 'Copia chiave pubblica', + 'ui.settings.pgp.download_public_key_button' => 'Scarica chiave pubblica', + 'ui.settings.pgp.copy_public_key_success' => 'Chiave pubblica copiata.', + 'ui.settings.pgp.key_details_title' => 'Dettagli chiave PGP', + 'ui.settings.pgp.algorithm' => 'Algoritmo', + 'ui.compose.pgp.title' => 'PGP', + 'ui.compose.pgp.encrypt_netmail' => 'Cifra questo netmail', + 'ui.compose.pgp.encrypt_netmail_help' => 'Cifra il messaggio per il destinatario usando la sua chiave pubblica pubblicata.', + 'ui.compose.pgp.sign_echomail' => 'Firma questo echomail', + 'ui.compose.pgp.sign_echomail_help' => 'Firma il messaggio con la tua chiave privata gestita salvata.', + 'ui.compose.pgp.encrypt_only_notice' => 'La cifratura del netmail richiede solo la chiave pubblica del destinatario. Firma e decifratura richiedono la tua chiave privata gestita.', + 'ui.compose.pgp.encrypt_recipient_label' => 'Chiave pubblica del destinatario', + 'ui.compose.pgp.encrypt_recipient_help' => 'Scegli la chiave pubblica che verra usata per cifrare questo netmail.', + 'ui.compose.pgp.encrypt_recipient_placeholder' => 'Seleziona la chiave pubblica da usare per la cifratura', + 'ui.compose.pgp.encrypt_no_key' => 'Nessuna chiave pubblica corrispondente trovata.', + 'ui.compose.pgp.encrypt_no_key_help' => 'Inserisci un destinatario che abbia una chiave pubblica pubblicata.', + 'ui.compose.pgp.encrypting_for' => 'Cifratura per {recipient}', + 'ui.pgp.notice' => 'Le azioni PGP usano la tua chiave privata gestita salvata. Se hai caricato solo una chiave pubblica, firma e decifratura non sono disponibili.', + 'ui.pgp.decrypt_button' => 'Decifra', + 'ui.pgp.decrypt_help' => 'Questo messaggio e cifrato con PGP.', + 'ui.pgp.passphrase_prompt' => 'Inserisci la tua passphrase PGP per decifrare questo messaggio.', + 'ui.pgp.decrypted' => 'PGP decifrato', + 'ui.pgp.verifying' => 'Verifica della firma PGP in corso...', + 'ui.pgp.verified' => 'Firma PGP verificata', + 'ui.pgp.no_public_key' => 'Chiave pubblica PGP non trovata', + 'ui.pgp.invalid' => 'Firma PGP non valida', + 'ui.keyserver.title' => 'Keyserver PGP', + 'ui.keyserver.manage_keys' => 'Gestisci le mie chiavi', + 'ui.keyserver.search_label' => 'Cerca', + 'ui.keyserver.search_placeholder' => 'Nome utente, nome reale, email o fingerprint', + 'ui.keyserver.search_help' => 'Cerca per nome account, indirizzo email, fingerprint completo o una ricerca remota qualificata come awehttam@227:1/200 o foobar@claudes.lovelybits.org.', + 'ui.keyserver.search_button' => 'Cerca', + 'ui.keyserver.results_heading' => 'Chiavi pubbliche', + 'ui.keyserver.col_user' => 'Utente', + 'ui.keyserver.col_fingerprint' => 'Fingerprint', + 'ui.keyserver.col_type' => 'Tipo', + 'ui.keyserver.col_created' => 'Creata', + 'ui.keyserver.col_actions' => 'Azioni', + 'ui.keyserver.primary_badge' => 'Principale', + 'ui.keyserver.download_button' => 'Scarica', + 'ui.keyserver.no_results' => 'Nessuna chiave pubblica corrisponde alla ricerca.', ]; diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 67274628e..801c441e6 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -86,6 +86,10 @@ 'errors.messages.send.invalid_type' => 'Tipo di messaggio non valido', 'errors.messages.send.failed' => 'Impossibile inviare il messaggio', 'errors.messages.send.exception' => 'Impossibile inviare il messaggio', + 'errors.pgp.decrypt_failed' => 'Impossibile decifrare il messaggio PGP.', + 'errors.pgp.recipient_required' => 'Impossibile risolvere una chiave pubblica per il destinatario.', + 'errors.pgp.passphrase_required' => 'Inserisci la tua passphrase PGP.', + 'errors.pgp.private_key_required' => 'Per firmare e necessaria una chiave privata gestita.', // Notify 'errors.notify.user_id_missing' => 'Impossibile risolvere la sessione utente', @@ -720,4 +724,17 @@ 'errors.meshcore.not_found' => 'Contatto non trovato.', 'errors.meshcore.qr_unrecognized' => 'Formato QR non riconosciuto.', 'errors.meshcore.qr_camera_denied' => 'Accesso alla fotocamera negato.', + 'errors.pgp.load_failed' => 'Impossibile caricare le chiavi PGP.', + 'errors.pgp.public_key_required' => 'E richiesta una chiave pubblica.', + 'errors.pgp.invalid_key' => 'Chiave pubblica PGP non valida.', + 'errors.pgp.save_failed' => 'Impossibile salvare la chiave PGP.', + 'errors.pgp.invalid_keypair' => 'Coppia di chiavi PGP non valida.', + 'errors.pgp.key_not_found' => 'Chiave PGP non trovata.', + 'errors.pgp.private_key_not_found' => 'Chiave privata non trovata.', + 'errors.pgp.delete_failed' => 'Impossibile eliminare la chiave PGP.', + 'errors.pgp.disabled' => 'PGP e disabilitato su questo sistema.', + 'errors.pgp.managed_disabled' => 'La generazione di chiavi PGP gestite e disabilitata su questo sistema.', + 'errors.pgp.passphrase_too_short' => 'Usa una passphrase PGP piu lunga.', + 'errors.pgp.passphrase_mismatch' => 'La conferma della passphrase non corrisponde.', + 'errors.pgp.generation_failed' => 'Impossibile generare la chiave PGP gestita.', ]; diff --git a/database/migrations/v20260602033958_add_user_pgp_keys.sql b/database/migrations/v20260602033958_add_user_pgp_keys.sql new file mode 100644 index 000000000..585419210 --- /dev/null +++ b/database/migrations/v20260602033958_add_user_pgp_keys.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS user_pgp_keys ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fingerprint CHAR(40) NOT NULL UNIQUE, + armored_public_key TEXT NOT NULL, + source VARCHAR(16) NOT NULL, + label VARCHAR(120), + user_id_string VARCHAR(255), + email VARCHAR(255), + key_algorithm VARCHAR(32), + key_created_at TIMESTAMPTZ, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT user_pgp_keys_source_check CHECK (source IN ('uploaded', 'managed')) +); + +CREATE INDEX IF NOT EXISTS user_pgp_keys_user_id_idx + ON user_pgp_keys(user_id); + +CREATE INDEX IF NOT EXISTS user_pgp_keys_email_idx + ON user_pgp_keys(email); + +CREATE UNIQUE INDEX IF NOT EXISTS user_pgp_keys_primary_user_idx + ON user_pgp_keys(user_id) + WHERE is_primary = TRUE; + +CREATE TABLE IF NOT EXISTS user_pgp_private_keys ( + id SERIAL PRIMARY KEY, + pgp_key_id INTEGER NOT NULL UNIQUE REFERENCES user_pgp_keys(id) ON DELETE CASCADE, + encrypted_private_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/database/migrations/v20260602191017_add_pgp_contact_keys.sql b/database/migrations/v20260602191017_add_pgp_contact_keys.sql new file mode 100644 index 000000000..9364dd71c --- /dev/null +++ b/database/migrations/v20260602191017_add_pgp_contact_keys.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS user_pgp_contact_keys ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fingerprint CHAR(40) NOT NULL, + armored_public_key TEXT NOT NULL, + source VARCHAR(16) NOT NULL DEFAULT 'address_book', + label VARCHAR(120), + user_id_string VARCHAR(255), + email VARCHAR(255), + key_algorithm VARCHAR(32), + key_created_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT user_pgp_contact_keys_user_fingerprint_unique UNIQUE (user_id, fingerprint), + CONSTRAINT user_pgp_contact_keys_source_check CHECK (source IN ('address_book', 'imported', 'remote_lookup')) +); + +CREATE INDEX IF NOT EXISTS user_pgp_contact_keys_user_id_idx + ON user_pgp_contact_keys(user_id); + +CREATE INDEX IF NOT EXISTS user_pgp_contact_keys_email_idx + ON user_pgp_contact_keys(email); + +ALTER TABLE address_book + ADD COLUMN IF NOT EXISTS pgp_contact_key_id INTEGER REFERENCES user_pgp_contact_keys(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS address_book_pgp_contact_key_id_idx + ON address_book(pgp_contact_key_id); diff --git a/database/migrations/v20260603181626_increase_user_settings_theme_length.sql b/database/migrations/v20260603181626_increase_user_settings_theme_length.sql new file mode 100644 index 000000000..855935680 --- /dev/null +++ b/database/migrations/v20260603181626_increase_user_settings_theme_length.sql @@ -0,0 +1,5 @@ +-- Migration: 20260603181626 - increase user_settings theme length +-- Created: 2026-06-03 18:16:26 UTC + +ALTER TABLE user_settings + ALTER COLUMN theme TYPE VARCHAR(300); diff --git a/database/migrations/v20260606034151_add_meshcore_node_adverts.sql b/database/migrations/v20260606034151_add_meshcore_node_adverts.sql new file mode 100644 index 000000000..5723fab33 --- /dev/null +++ b/database/migrations/v20260606034151_add_meshcore_node_adverts.sql @@ -0,0 +1,84 @@ +-- Migration: 20260606034151 - add_meshcore_node_adverts +-- Created: 2026-06-06 03:41:51 UTC + +CREATE TABLE IF NOT EXISTS meshcore_node_adverts ( + id SERIAL PRIMARY KEY, + public_key VARCHAR(64) NOT NULL UNIQUE, + bridge_node_id VARCHAR(64), + name VARCHAR(100) NOT NULL, + adv_type VARCHAR(50) NOT NULL DEFAULT 'meshcore', + latitude NUMERIC(10,6) NOT NULL, + longitude NUMERIC(10,6) NOT NULL, + hop_count SMALLINT, + bbs_name VARCHAR(50) NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS meshcore_node_adverts_last_seen_idx + ON meshcore_node_adverts(last_seen_at DESC); + +CREATE INDEX IF NOT EXISTS meshcore_node_adverts_bridge_node_id_idx + ON meshcore_node_adverts(bridge_node_id); + +ALTER TABLE packet_bbs_nodes + ADD COLUMN IF NOT EXISTS public_key VARCHAR(64); + +CREATE UNIQUE INDEX IF NOT EXISTS packet_bbs_nodes_public_key_idx + ON packet_bbs_nodes(public_key) + WHERE public_key IS NOT NULL; + +INSERT INTO meshcore_node_adverts ( + public_key, + bridge_node_id, + name, + adv_type, + latitude, + longitude, + hop_count, + bbs_name, + last_seen_at, + created_at, + updated_at +) +SELECT DISTINCT ON (c.public_key) + c.public_key, + NULL, + c.ssid, + COALESCE(NULLIF(c.network_type, ''), 'meshcore'), + c.latitude::NUMERIC(10,6), + c.longitude::NUMERIC(10,6), + c.hop_count, + COALESCE(NULLIF(c.bbs_name, ''), 'Unknown'), + COALESCE(c.last_seen_at, c.date_updated, c.date_added, c.created_at, NOW()), + COALESCE(c.created_at, c.date_added, NOW()), + COALESCE(c.date_updated, c.last_seen_at, c.date_added, c.created_at, NOW()) +FROM cwn_networks c +WHERE c.source_type = 'meshcore' + AND c.public_key IS NOT NULL +ORDER BY c.public_key, + COALESCE(c.last_seen_at, c.date_updated, c.date_added, c.created_at, NOW()) DESC, + c.id DESC +ON CONFLICT (public_key) DO UPDATE SET + name = EXCLUDED.name, + adv_type = EXCLUDED.adv_type, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + hop_count = EXCLUDED.hop_count, + bbs_name = EXCLUDED.bbs_name, + last_seen_at = EXCLUDED.last_seen_at, + updated_at = EXCLUDED.updated_at; + +UPDATE packet_bbs_nodes n +SET public_key = src.public_key +FROM ( + SELECT DISTINCT ON (left(public_key, 12)) + left(public_key, 12) AS node_prefix, + public_key + FROM meshcore_node_adverts + ORDER BY left(public_key, 12), last_seen_at DESC, id DESC +) AS src +WHERE n.interface_type = 'meshcore' + AND n.public_key IS NULL + AND n.node_id = src.node_prefix; diff --git a/docs/ANSI_Support.md b/docs/ANSI_Support.md index c3f64a95d..54c305d51 100644 --- a/docs/ANSI_Support.md +++ b/docs/ANSI_Support.md @@ -269,6 +269,16 @@ ANSI_RENDERER_MODE=grouped # default ANSI_RENDERER_MODE=perchar # one span per character ``` +Pipe-code recognition has a separate environment control: + +```env +PIPE_CODE_PARSER_MODE=decimal_relaxed +``` + +- `strict` keeps the conservative uppercase-only boundary checks. +- `decimal_relaxed` is the default mode and greedily accepts two-digit decimal codes such as `|01` even when followed by uppercase text. +- `loose` restores the older permissive behavior for testing. + Use `perchar` if you observe rendering differences with specific art files and want to compare. `grouped` is recommended for normal operation. ## Compatibility diff --git a/docs/API.md b/docs/API.md index ffb9b60a3..8c0773df0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -174,6 +174,19 @@ Array of address book entries matching the search criteria |-------|------|-------------| | `success` | boolean | Always true on success | | `entries` | array | Array of address book entry objects | +| `entries[].id` | integer | Entry ID | +| `entries[].name` | string | Contact display name | +| `entries[].messaging_user_id` | integer\|null | BBS user ID if linked to a local user | +| `entries[].node_address` | string\|null | FTN node address | +| `entries[].email` | string\|null | Email address | +| `entries[].description` | string\|null | Free-text notes | +| `entries[].always_crashmail` | boolean | Always send crashmail to this address | +| `entries[].pgp_contact_key_id` | integer\|null | Linked saved correspondent-key ID | +| `entries[].pgp_key_fingerprint` | string\|null | Linked correspondent-key fingerprint | +| `entries[].pgp_key_user_id_string` | string\|null | Linked correspondent-key user ID | +| `entries[].pgp_key_label` | string\|null | Linked correspondent-key label | +| `entries[].created_at` | string | ISO 8601 creation timestamp | +| `entries[].updated_at` | string | ISO 8601 last-update timestamp | **Error Responses** @@ -203,6 +216,20 @@ Single address book entry object |-------|------|-------------| | `success` | boolean | True if entry found | | `entry` | object | Address book entry details | +| `entry.id` | integer | Entry ID | +| `entry.name` | string | Contact display name | +| `entry.messaging_user_id` | integer\|null | BBS user ID if linked to a local user | +| `entry.node_address` | string\|null | FTN node address | +| `entry.email` | string\|null | Email address | +| `entry.description` | string\|null | Free-text notes | +| `entry.always_crashmail` | boolean | Always send crashmail to this address | +| `entry.pgp_contact_key_id` | integer\|null | Linked saved correspondent-key ID | +| `entry.pgp_key_fingerprint` | string\|null | Linked correspondent-key fingerprint | +| `entry.pgp_key_user_id_string` | string\|null | Linked correspondent-key user ID | +| `entry.pgp_key_label` | string\|null | Linked correspondent-key label | +| `entry.pgp_armored_public_key` | string\|null | Linked correspondent ASCII-armored public key | +| `entry.created_at` | string | ISO 8601 creation timestamp | +| `entry.updated_at` | string | ISO 8601 last-update timestamp | **Error Responses** @@ -226,7 +253,12 @@ Address book entry data | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Contact name | -| `address` | string | Yes | FTN address or email | +| `messaging_user_id` | string | Yes | User ID or handle used for FTN messaging | +| `node_address` | string | Yes | FTN destination address | +| `email` | string | No | Reference email address | +| `description` | string | No | Free-text notes | +| `always_crashmail` | boolean | No | Whether compose should default this contact to crashmail | +| `pgp_public_key` | string | No | ASCII-armored correspondent public key to save and link to the contact | **Response** _(JSON)_ @@ -265,7 +297,12 @@ Updated address book entry data (partial updates supported) | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | No | Contact name | -| `address` | string | No | FTN address or email | +| `messaging_user_id` | string | No | User ID or handle used for FTN messaging | +| `node_address` | string | No | FTN destination address | +| `email` | string | No | Reference email address | +| `description` | string | No | Free-text notes | +| `always_crashmail` | boolean | No | Whether compose should default this contact to crashmail | +| `pgp_public_key` | string | No | ASCII-armored correspondent public key to save and link to the contact; submit an empty string to unlink | **Response** _(JSON)_ @@ -341,6 +378,21 @@ Array of matching autocomplete entries |-------|------|-------------| | `success` | boolean | True on success | | `entries` | array | Matching address-book and local-user entries (limited) | +| `entries[].id` | integer | Entry ID | +| `entries[].name` | string | Contact display name | +| `entries[].messaging_user_id` | integer\|null | BBS user ID if linked to a local user | +| `entries[].node_address` | string\|null | FTN node address | +| `entries[].email` | string\|null | Email address | +| `entries[].description` | string\|null | Free-text notes | +| `entries[].always_crashmail` | boolean | Always send crashmail to this address | +| `entries[].pgp_contact_key_id` | integer\|null | Linked saved correspondent-key ID | +| `entries[].pgp_key_fingerprint` | string\|null | Linked correspondent-key fingerprint | +| `entries[].pgp_key_user_id_string` | string\|null | Linked correspondent-key user ID | +| `entries[].pgp_key_label` | string\|null | Linked correspondent-key label | +| `entries[].created_at` | string | ISO 8601 creation timestamp | +| `entries[].updated_at` | string | ISO 8601 last-update timestamp | +| `entries[].node_system_name` | string\|null | System name from nodelist (search results only) | +| `entries[].node_domain` | string\|null | Network domain from nodelist (search results only) | **Error Responses** @@ -363,7 +415,10 @@ Address book statistics object | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True on success | -| `stats` | object | Statistics object (e.g., total_entries, etc.) | +| `stats` | object | Statistics object | +| `stats.total_entries` | integer | Total number of entries in the address book | +| `stats.entries_with_email` | integer | Number of entries that have an email address | +| `stats.entries_with_description` | integer | Number of entries that have a description | **Error Responses** @@ -522,7 +577,10 @@ Token validation result with user information | Field | Type | Description | |-------|------|-------------| | `valid` | boolean | Token validity status | -| `userInfo` | object | User information object if valid | +| `userInfo` | object | User information object if valid (absent when `valid` is false) | +| `userInfo.user_id` | integer | BBS user ID | +| `userInfo.username` | string | Username | +| `userInfo.door` | string\|null | Door/service identifier the token was issued for | **Error Responses** @@ -694,7 +752,32 @@ Returns operational status of the BinkP daemon including connection state, queue **Response** _(JSON)_ -BinkP daemon status object (structure varies by implementation) +BinkP daemon operational status + +| Field | Type | Description | +|-------|------|-------------| +| `system` | object | Static system configuration | +| `system.address` | string | FidoNet address of this node | +| `system.sysop` | string | Sysop name | +| `system.location` | string | System location string | +| `system.hostname` | string | BinkP listen hostname | +| `system.port` | integer | BinkP listen port | +| `schedule` | object | Map of uplink address → schedule status entry | +| `schedule[addr].address` | string | Uplink FidoNet address | +| `schedule[addr].schedule` | string | Cron-style poll schedule expression | +| `schedule[addr].enabled` | boolean | Whether this uplink is enabled | +| `schedule[addr].last_poll` | string | ISO 8601 UTC timestamp of last poll, or `"Never"` | +| `schedule[addr].next_poll` | string | ISO 8601 UTC timestamp of next scheduled poll, or `"Unknown"` | +| `schedule[addr].due_now` | boolean | Whether a poll is currently due | +| `queues` | object | Queue statistics | +| `queues.inbound.pending_files` | integer | Packets awaiting processing in the inbound directory | +| `queues.inbound.error_files` | integer | Files in the inbound error directory | +| `queues.inbound.last_check` | string | Timestamp of last inbound queue check | +| `queues.outbound.pending_files` | integer | Packets queued for outbound transmission | +| `queues.outbound.total_size` | integer | Total byte size of outbound packets | +| `queues.outbound.total_messages` | integer | Total message count across outbound packets | +| `queues.outbound.last_check` | string | Timestamp of last outbound queue check | +| `timestamp` | string | ISO 8601 UTC timestamp of when this status was generated | **Error Responses** @@ -728,7 +811,10 @@ Poll trigger confirmation |-------|------|-------------| | `success` | boolean | True if poll was triggered | | `message_code` | string | Localization key for UI message | -| `result` | mixed | Result from daemon poll operation | +| `result` | object | Daemon process result | +| `result.exit_code` | integer | Daemon exit code (0 = success) | +| `result.stdout` | string | Standard output from daemon process | +| `result.stderr` | string | Standard error output from daemon process | **Error Responses** @@ -754,7 +840,10 @@ Poll trigger confirmation |-------|------|-------------| | `success` | boolean | True if poll was triggered | | `message_code` | string | Localization key for UI message | -| `result` | mixed | Result from daemon poll operation | +| `result` | object | Daemon process result | +| `result.exit_code` | integer | Daemon exit code (0 = success) | +| `result.stdout` | string | Standard output from daemon process | +| `result.stderr` | string | Standard error output from daemon process | **Error Responses** @@ -781,6 +870,9 @@ Processing initiation status with result details. | `success` | boolean | Always true on success | | `message_code` | string | Localization key: 'ui.api.binkp.process_packets_started' | | `result` | object | Daemon processing result details | +| `result.exit_code` | integer | Daemon exit code (0 = success) | +| `result.stdout` | string | Standard output from packet processor | +| `result.stderr` | string | Standard error output from packet processor | **Error Responses** @@ -802,7 +894,21 @@ Array of uplink configurations. | Field | Type | Description | |-------|------|-------------| -| `[array]` | array | List of uplink objects with address, credentials, and settings | +| `[array]` | array | Array of uplink configuration objects | +| `[].address` | string | FidoNet address of the uplink (e.g. `1:234/567`) | +| `[].me` | string | Local address to present to this uplink | +| `[].domain` | string | FTN domain name (e.g. `fidonet`) | +| `[].networks` | array of strings | Additional network names served by this uplink | +| `[].hostname` | string | Hostname or IP address | +| `[].port` | integer | TCP port (default 24554) | +| `[].password` | string | BinkP session password | +| `[].pkt_password` | string | FTS-0001 packet password | +| `[].tic_password` | string | TIC file password | +| `[].areafix_password` | string | AreaFix robot password | +| `[].filefix_password` | string | FileFix robot password | +| `[].enabled` | boolean | Whether this uplink is enabled | +| `[].default` | boolean | Whether this is the default uplink | +| `[].send_domain_in_addr` | boolean | Whether to include domain in the presented address | **Error Responses** @@ -861,12 +967,12 @@ Uplink configuration parameters. **Response** _(JSON)_ -Created uplink configuration. +Uplink creation confirmation. | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Uplink created successfully | -| `uplink` | object | Uplink configuration details | +| `message_code` | string | Localization key: `ui.api.binkp.uplink_added` | --- @@ -894,12 +1000,12 @@ Updated uplink configuration fields. **Response** _(JSON)_ -Updated uplink configuration. +Update confirmation. | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Update completed | -| `uplink` | object | Modified uplink details | +| `message_code` | string | Localization key: `ui.api.binkp.uplink_updated` | --- @@ -937,7 +1043,15 @@ Array of inbound files. | Field | Type | Description | |-------|------|-------------| -| `[array]` | array | List of file objects with name, size, timestamp, and status | +| `success` | boolean | Operation success flag | +| `pending` | array | Array of files in the inbound queue awaiting processing | +| `pending[].filename` | string | Packet filename | +| `pending[].size` | integer | File size in bytes | +| `pending[].modified` | string | Last modified timestamp (YYYY-MM-DD HH:MM:SS) | +| `errors` | array | Array of files in the error queue | +| `errors[].filename` | string | Packet filename | +| `errors[].size` | integer | File size in bytes | +| `errors[].modified` | string | Last modified timestamp (YYYY-MM-DD HH:MM:SS) | **Error Responses** @@ -959,7 +1073,16 @@ Array of outbound file objects with metadata | Field | Type | Description | |-------|------|-------------| -| `files` | array | List of queued outbound files | +| `success` | boolean | Operation success flag | +| `files` | array | Array of queued outbound packet files | +| `files[].filename` | string | Packet filename | +| `files[].size` | integer | File size in bytes | +| `files[].created` | string | File creation timestamp (YYYY-MM-DD HH:MM:SS) | +| `files[].modified` | string | File last modified timestamp (YYYY-MM-DD HH:MM:SS) | +| `files[].path` | string | Full filesystem path to the packet file | +| `files[].message_count` | integer | Number of FTN messages contained in the packet | +| `files[].dest_address` | string | Destination FTN address parsed from packet header | +| `files[].orig_address` | string | Origin FTN address parsed from packet header | **Error Responses** @@ -984,6 +1107,9 @@ Processing completion status with result details | `success` | boolean | Whether processing completed successfully | | `message_code` | string | Localization key for UI message | | `result` | object | Processing result details from daemon | +| `result.exit_code` | integer | Daemon exit code (0 = success) | +| `result.stdout` | string | Standard output from packet processor | +| `result.stderr` | string | Standard error output from packet processor | **Error Responses** @@ -1009,6 +1135,9 @@ Polling completion status with result details | `success` | boolean | Whether polling completed successfully | | `message_code` | string | Localization key for UI message | | `result` | object | Polling result details from daemon | +| `result.exit_code` | integer | Daemon exit code (0 = spawned successfully) | +| `result.stdout` | string | Standard output (empty for async spawned poll) | +| `result.stderr` | string | Standard error output (empty for async spawned poll) | **Error Responses** @@ -1039,7 +1168,24 @@ Packet inspection details including structure and contents | Field | Type | Description | |-------|------|-------------| -| `packet_info` | object | Packet metadata and structure | +| `success` | boolean | Operation success flag | +| `packet` | object | FTS-0001 packet header metadata | +| `packet.orig_address` | string | Origin FTN address from packet header | +| `packet.dest_address` | string | Destination FTN address from packet header | +| `packet.created` | string | Packet creation timestamp from header | +| `packet.has_password` | boolean | Whether packet has a non-empty password field | +| `packet.packet_version` | integer | FTS-0001 packet version number | +| `packet.product_code` | string | Hex product code from packet header | +| `packet.file_size` | integer | Packet file size in bytes | +| `messages` | array | Array of message headers parsed from the packet | +| `messages[].from` | string | Sender name | +| `messages[].to` | string | Recipient name | +| `messages[].subject` | string | Message subject | +| `messages[].date` | string | Message date string from packet header | +| `messages[].orig_addr` | string | Origin net:node address | +| `messages[].dest_addr` | string | Destination net:node address | +| `messages[].flags` | array of strings | FTS-0001 attribute flag labels (e.g. `Pvt`, `Crash`, `Rcvd`) | +| `messages[].cost` | integer | Message cost field | **Error Responses** @@ -1101,7 +1247,24 @@ Queue packet inspection details including structure and contents | Field | Type | Description | |-------|------|-------------| -| `packet_info` | object | Packet metadata and structure | +| `success` | boolean | Operation success flag | +| `packet` | object | FTS-0001 packet header metadata | +| `packet.orig_address` | string | Origin FTN address from packet header | +| `packet.dest_address` | string | Destination FTN address from packet header | +| `packet.created` | string | Packet creation timestamp from header | +| `packet.has_password` | boolean | Whether packet has a non-empty password field | +| `packet.packet_version` | integer | FTS-0001 packet version number | +| `packet.product_code` | string | Hex product code from packet header | +| `packet.file_size` | integer | Packet file size in bytes | +| `messages` | array | Array of message headers parsed from the packet | +| `messages[].from` | string | Sender name | +| `messages[].to` | string | Recipient name | +| `messages[].subject` | string | Message subject | +| `messages[].date` | string | Message date string from packet header | +| `messages[].orig_addr` | string | Origin net:node address | +| `messages[].dest_addr` | string | Destination net:node address | +| `messages[].flags` | array of strings | FTS-0001 attribute flag labels (e.g. `Pvt`, `Crash`, `Rcvd`) | +| `messages[].cost` | integer | Message cost field | **Error Responses** @@ -1159,11 +1322,16 @@ Enumerates files contained within a bundle or archive packet from the kept-packe **Response** _(JSON)_ -List of files contained in the bundle +List of .pkt files contained in the bundle | Field | Type | Description | |-------|------|-------------| -| `files` | array | Array of bundled file objects with metadata | +| `success` | boolean | Operation success flag | +| `bundle` | string | Bundle filename | +| `bundle_size` | integer | Bundle file size in bytes | +| `packets` | array | Array of .pkt files found inside the bundle | +| `packets[].filename` | string | Packet filename within the bundle | +| `packets[].size` | integer | Uncompressed packet size in bytes | **Error Responses** @@ -1191,7 +1359,28 @@ Retrieves detailed inspection data for a specific packet within a kept bundle (i **Response** _(JSON)_ -Packet inspection data with metadata and contents +Parsed FTS-0001 packet header and per-message header list + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `packet` | object | Packet-level header fields | +| `packet.orig_address` | string | Originating FidoNet address (zone:net/node.point) | +| `packet.dest_address` | string | Destination FidoNet address | +| `packet.created` | string | Packet creation timestamp as `YYYY-MM-DD HH:MM:SS` | +| `packet.has_password` | boolean | Whether a session password was set in the packet header | +| `packet.packet_version` | integer | FTS-0001 packet version field value | +| `packet.product_code` | string | Two-character hex product code | +| `packet.file_size` | integer | Packet file size in bytes | +| `messages` | array | Per-message header entries (up to 1000) | +| `messages[].from` | string | Sender name (CP437 decoded) | +| `messages[].to` | string | Recipient name (CP437 decoded) | +| `messages[].subject` | string | Message subject (CP437 decoded) | +| `messages[].date` | string | Message date string from packet header | +| `messages[].orig_addr` | string | Originating net/node address | +| `messages[].dest_addr` | string | Destination net/node address | +| `messages[].flags` | array | Attribute flag labels (e.g. `["Pvt"]`, `["Crash", "Local"]`) | +| `messages[].cost` | integer | Message cost field | **Error Responses** @@ -1216,9 +1405,15 @@ Downloads a file from a kept bundle as an attachment. Requires BinkP admin privi | `date` | string | No | Date identifier for the bundle | | `filename` | string | Yes | Filename to download | -**Response** _(JSON)_ +**Response** _(binary)_ -Binary file content with Content-Disposition attachment header +Raw bundle file bytes with download headers + +| Header | Value | +|--------|-------| +| `Content-Type` | `application/octet-stream` | +| `Content-Length` | File size in bytes | +| `Content-Disposition` | `attachment; filename=""` | **Error Responses** @@ -1244,7 +1439,24 @@ Retrieves a list of kept packet bundles (inbound or outbound). Requires BinkP ad **Response** _(JSON)_ -Array of kept packet bundles with metadata +Kept packets grouped by date directory, newest first + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `groups` | array | Date-grouped list of packet entries | +| `groups[].date` | string | Date directory label (e.g. `"Mar-18-2026"`), empty for loose root-level files | +| `groups[].packets` | array | Packet and bundle records within this date group | +| `groups[].packets[].file_type` | string | Either `"pkt"` (raw packet) or `"bundle"` (arcmail archive) | +| `groups[].packets[].filename` | string | Filename within the keep directory | +| `groups[].packets[].size` | integer | File size in bytes | +| `groups[].packets[].modified` | string | ISO 8601 UTC last-modified timestamp | +| `groups[].packets[].modified_ts` | integer | Unix timestamp of last modification | +| `groups[].packets[].message_count` | integer | Number of messages _(pkt only)_ | +| `groups[].packets[].dest_address` | string | Destination FidoNet address _(pkt only)_ | +| `groups[].packets[].orig_address` | string | Originating FidoNet address _(pkt only)_ | +| `groups[].latest_modified_ts` | integer | Unix timestamp of the most recently modified file in this group | +| `total` | integer | Total number of packet/bundle files across all groups | **Error Responses** @@ -1269,7 +1481,12 @@ Fetches recent BinkP protocol logs. Requires BinkP admin privileges. Supports co **Response** _(JSON)_ -Array of log entries +Recent log lines from all BinkP-related log files + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `logs` | array of strings | Log lines in `": "` format, up to `lines` entries per file, newest last | **Error Responses** @@ -1293,7 +1510,17 @@ Searches BinkP logs for entries matching a query. Requires BinkP admin privilege **Response** _(JSON)_ -Array of matching log entries +PID-contextual log search results — all lines from sessions that contain the query term + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `lines` | array | Log line entries (all lines from matching PIDs across all BinkP log files) | +| `lines[].line` | string | Full log line prefixed with `": "` | +| `lines[].is_match` | boolean | True if this line itself contains the query term (as opposed to being context from a matching PID) | +| `lines[].pid` | string | Process ID extracted from the log line | +| `pid_count` | integer | Number of distinct PIDs whose sessions contained the query term | +| `match_count` | integer | Number of lines that directly matched the query term | **Error Responses** @@ -1327,8 +1554,19 @@ Bulletins and metadata |-------|------|-------------| | `success` | boolean | Always true on success | | `bulletins` | array | Array of active bulletin objects | +| `bulletins[].id` | integer | Bulletin ID | +| `bulletins[].title` | string | Bulletin title | +| `bulletins[].body` | string | Bulletin body text (raw source) | +| `bulletins[].format` | string | Body format (`markdown`, `html`, `plain`) | +| `bulletins[].sort_order` | integer | Display sort order | +| `bulletins[].is_active` | boolean | Whether bulletin is active | +| `bulletins[].active_from` | string\|null | ISO 8601 start date (null = always active) | +| `bulletins[].active_until` | string\|null | ISO 8601 expiry date (null = no expiry) | +| `bulletins[].created_by` | integer | User ID of bulletin creator | +| `bulletins[].is_read` | boolean | Whether the authenticated user has read this bulletin | +| `bulletins[].body_html` | string | Bulletin body rendered to HTML | | `unread_count` | integer | Number of unread bulletins for this user | -| `bulletin_display_mode` | string | Configured display mode (e.g., 'popup', 'list', 'none') | +| `bulletin_display_mode` | string | Configured display mode (e.g., `popup`, `list`, `none`) | **Error Responses** @@ -1702,6 +1940,11 @@ Dashboard statistics object. All counts are for the authenticated user. | `new_files` | integer | New approved files since last visit | | `new_echoareas` | integer | Echo areas created in the last 30 days | | `recent_echoareas` | array | Up to 8 most recently created echo area objects | +| `recent_echoareas[].id` | integer | Echo area ID | +| `recent_echoareas[].tag` | string | Echo area tag name | +| `recent_echoareas[].domain` | string\|null | Network domain | +| `recent_echoareas[].description` | string\|null | Echo area description | +| `recent_echoareas[].created_at` | string | ISO 8601 creation timestamp | | `echomail_max_id` | integer | Current max echomail row ID (used for badge tracking) | | `chat_max_id` | integer | Current max chat message ID | | `files_max_id` | integer | Current max file ID | @@ -1767,9 +2010,14 @@ Current authentication state | Field | Type | Description | |-------|------|-------------| | `user` | object | Current user object (null if not authenticated) | +| `user.user_id` | integer | User ID | +| `user.username` | string | Username | +| `user.real_name` | string | Real name | +| `user.email` | string\|null | Email address | +| `user.is_admin` | boolean | Admin flag | | `is_admin` | boolean | Whether current user has admin privileges | | `cookie_present` | boolean | Whether session cookie exists | -| `cookie_value` | string | Session cookie value (null if not present) | +| `cookie_value` | string\|null | Session cookie value (null if not present) | **Error Responses** @@ -1880,14 +2128,23 @@ Single echo area object with extended metadata | Field | Type | Description | |-------|------|-------------| -| `id` | integer | Echo area ID | -| `tag` | string | Echo area tag | -| `description` | string | Description | -| `domain` | string | Domain (e.g., 'lovlynet') | -| `is_sysop_only` | boolean | Sysop-only flag | -| `lovlynet_metadata` | object | Remote LovlyNet metadata if domain is 'lovlynet' | -| `lovlynet_setting_issues` | array | Array of setting mismatches with recommended vs actual values | -| `lovlynet_has_setting_issues` | boolean | Whether any setting mismatches exist | +| `echoarea` | object | Full echo area record including all database columns | +| `echoarea.id` | integer | Echo area ID | +| `echoarea.tag` | string | Echo area tag | +| `echoarea.description` | string | Description | +| `echoarea.domain` | string | Domain (e.g., 'lovlynet') | +| `echoarea.is_sysop_only` | boolean | Sysop-only flag | +| `echoarea.is_active` | boolean | Whether area is active | +| `echoarea.is_local` | boolean | Whether area is local-only (not forwarded) | +| `echoarea.color` | string | Hex color code for UI display | +| `echoarea.lovlynet_metadata` | object | Remote LovlyNet metadata if domain is 'lovlynet'; empty object otherwise | +| `echoarea.lovlynet_metadata.sysop_only` | boolean | LovlyNet recommended sysop-only setting | +| `echoarea.lovlynet_setting_issues` | array | Array of setting mismatches with recommended vs actual values | +| `echoarea.lovlynet_setting_issues[].setting` | string | Setting name that has a mismatch | +| `echoarea.lovlynet_setting_issues[].recommended` | boolean | LovlyNet recommended value | +| `echoarea.lovlynet_setting_issues[].actual` | boolean | Current local value | +| `echoarea.lovlynet_has_setting_issues` | boolean | Whether any setting mismatches exist | +| `echoarea.description_mismatch` | boolean | Whether local description differs from LovlyNet description | **Error Responses** @@ -2060,7 +2317,11 @@ Array of echo areas | Field | Type | Description | |-------|------|-------------| -| `echoareas` | array | List of echo area objects with id, tag, description, domain | +| `echoareas` | array | Array of minimal echo area objects | +| `echoareas[].id` | integer | Echo area ID | +| `echoareas[].tag` | string | Echo area tag | +| `echoareas[].description` | string | Human-readable description | +| `echoareas[].domain` | string | Domain name (e.g., `fidonet`, `lovlynet`) | --- @@ -2131,8 +2392,24 @@ Single file area object with full configuration | Field | Type | Description | |-------|------|-------------| -| `filearea` | object | File area configuration object | -| `iso_accessible` | boolean | Whether ISO mount point is readable (if applicable) | +| `filearea` | object | Full file area configuration (all database columns) | +| `filearea.id` | integer | File area ID | +| `filearea.tag` | string | File area tag | +| `filearea.description` | string | Description | +| `filearea.domain` | string | Domain name (e.g. `fidonet`) | +| `filearea.is_active` | boolean | Whether area is active | +| `filearea.is_local` | boolean | Whether area is local-only | +| `filearea.is_private` | boolean | Whether area is private | +| `filearea.is_public` | boolean | Whether area is publicly accessible without login | +| `filearea.area_type` | string | Area type: `normal` or `iso` | +| `filearea.iso_mount_point` | string|null | Path to ISO mount point (if area_type is `iso`) | +| `filearea.iso_accessible` | boolean | Whether ISO mount point is currently readable | +| `filearea.comment_echoarea_id` | integer|null | ID of linked comment echo area | +| `filearea.upload_permission` | integer | Upload permission level (1 = users, 2 = admin only) | +| `filearea.file_count` | integer | Number of approved files in area | +| `filearea.total_size` | integer | Total size of all files in bytes | +| `filearea.created_at` | string | ISO 8601 creation timestamp | +| `filearea.updated_at` | string | ISO 8601 last update timestamp | **Error Responses** @@ -2241,7 +2518,13 @@ Retrieves aggregated statistics for all file areas. Requires authentication. Ret **Response** _(JSON)_ -File area statistics object +File area statistics + +| Field | Type | Description | +|-------|------|-------------| +| `active_count` | integer | Number of active file areas (public-only when request is unauthenticated) | +| `total_files` | integer | Total approved file count across matching areas | +| `total_size` | integer | Total byte size of all files across matching areas | --- @@ -2309,7 +2592,12 @@ Import result with counters | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always true on success | -| `counters` | object | Import statistics (added, updated, skipped, etc.) | +| `counters` | object | Import statistics | +| `counters.imported` | integer | Number of new files added | +| `counters.updated` | integer | Number of existing files updated | +| `counters.skipped` | integer | Number of files skipped (unchanged or already present) | +| `counters.no_description` | integer | Number of files with no description available | +| `counters.errors` | integer | Number of files that failed to import | **Error Responses** @@ -2448,8 +2736,26 @@ Files and subfolders in the area | Field | Type | Description | |-------|------|-------------| -| `subfolders` | array | List of subdirectories | -| `files` | array | List of files in current folder | +| `subfolders` | array | List of subdirectory objects | +| `subfolders[].subfolder` | string | Subfolder path | +| `subfolders[].description` | string\|null | Display label from ISO metadata (if present) | +| `subfolders[].long_description` | string\|null | Extended ISO subfolder description | +| `subfolders[].subdir_id` | integer\|null | ID of the iso_subdir record if applicable | +| `files` | array | List of file objects in current folder | +| `files[].id` | integer | File ID | +| `files[].filename` | string | File name | +| `files[].filesize` | integer | File size in bytes | +| `files[].short_description` | string | Brief description | +| `files[].long_description` | string | Extended description | +| `files[].status` | string | Approval status (approved, pending, rejected) | +| `files[].source_type` | string | Origin type (fidonet, user_upload, iso_import, url, etc.) | +| `files[].created_at` | string | Upload timestamp (ISO 8601) | +| `files[].subfolder` | string\|null | Subfolder path if in a subdirectory | +| `files[].owner_id` | integer\|null | User ID of uploader | +| `files[].area_tag` | string | Tag of the file area | +| `files[].is_shared` | boolean | Whether an active share link exists for this file | +| `subfolder` | string\|null | Current subfolder path (null at root level) | +| `subfolder_label` | string\|null | Display label for current subfolder (null at root level) | **Error Responses** @@ -2479,7 +2785,19 @@ Array of recent file objects | Field | Type | Description | |-------|------|-------------| -| `files` | array | List of file objects with metadata | +| `files` | array | List of file objects | +| `files[].id` | integer | File ID | +| `files[].filename` | string | File name | +| `files[].filesize` | integer | File size in bytes | +| `files[].short_description` | string | Brief description | +| `files[].created_at` | string | Upload timestamp (ISO 8601) | +| `files[].subfolder` | string\|null | Subfolder path if applicable | +| `files[].subfolder_label` | string\|null | Display label for subfolder from ISO metadata | +| `files[].source_type` | string | Origin type (fidonet, user_upload, iso_import, url, etc.) | +| `files[].area_tag` | string | Tag of the file area | +| `files[].domain` | string | Domain of the file area | +| `files[].is_local` | boolean | Whether the file area is local-only | +| `files[].is_shared` | boolean | Whether an active share link exists for this file | **Error Responses** @@ -2501,8 +2819,22 @@ User's uploads and summary statistics | Field | Type | Description | |-------|------|-------------| -| `files` | array | List of files uploaded by the user | -| `summary` | object | Upload statistics (count, total size, etc.) | +| `files` | array | List of file objects uploaded by the user | +| `files[].id` | integer | File ID | +| `files[].filename` | string | File name | +| `files[].filesize` | integer | File size in bytes | +| `files[].short_description` | string | Brief description | +| `files[].status` | string | Approval status (approved, pending, rejected) | +| `files[].created_at` | string | Upload timestamp (ISO 8601) | +| `files[].area_tag` | string | Tag of the file area | +| `files[].domain` | string | Domain of the file area | +| `files[].area_description` | string | Description of the file area | +| `summary` | object | Upload statistics | +| `summary.total_count` | integer | Total number of uploads | +| `summary.total_size` | integer | Total size in bytes of all uploads | +| `summary.pending_count` | integer | Number of uploads awaiting approval | +| `summary.approved_count` | integer | Number of approved uploads | +| `summary.rejected_count` | integer | Number of rejected uploads | **Error Responses** @@ -2530,7 +2862,15 @@ Search results with file metadata | Field | Type | Description | |-------|------|-------------| -| `results` | array | Array of matching files with id, filename, description, size, area_tag, and timestamps | +| `results` | array | Array of matching file objects | +| `results[].id` | integer | File ID | +| `results[].filename` | string | File name | +| `results[].short_description` | string | Brief description | +| `results[].filesize` | integer | File size in bytes | +| `results[].created_at` | string | Upload timestamp (ISO 8601) | +| `results[].area_id` | integer | File area ID | +| `results[].area_tag` | string | File area tag | +| `results[].subfolder` | string\|null | Subfolder path if applicable | **Error Responses** @@ -2558,10 +2898,22 @@ Complete file metadata object | Field | Type | Description | |-------|------|-------------| -| `id` | integer | File ID | -| `filename` | string | Original filename | -| `status` | string | File status (approved, pending, rejected) | -| `file_area_id` | integer | Associated file area ID | +| `file` | object | File details object | +| `file.id` | integer | File ID | +| `file.filename` | string | File name | +| `file.filesize` | integer | File size in bytes | +| `file.short_description` | string | Brief description | +| `file.long_description` | string | Extended description | +| `file.status` | string | Approval status (approved, pending, rejected) | +| `file.source_type` | string | Origin type (fidonet, user_upload, iso_import, url, etc.) | +| `file.created_at` | string | Upload timestamp (ISO 8601) | +| `file.updated_at` | string | Last update timestamp (ISO 8601) | +| `file.subfolder` | string\|null | Subfolder path if in a subdirectory | +| `file.url` | string\|null | External URL (for url-type files) | +| `file.owner_id` | integer\|null | User ID of uploader | +| `file.file_area_id` | integer | Associated file area ID | +| `file.virus_scanned` | boolean | Whether virus scan was performed | +| `file.virus_scan_result` | string\|null | Scan result (clean, infected, error, skipped) | **Error Responses** @@ -2591,7 +2943,10 @@ Rehatch operation result | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether rehatch completed successfully | -| `result` | object | Daemon output and results | +| `result` | object | Command execution result from admin daemon | +| `result.exit_code` | integer | Exit code from file_hatch.php (0 on success) | +| `result.stdout` | string | Standard output from file_hatch.php | +| `result.stderr` | string | Standard error output from file_hatch.php | **Error Responses** @@ -2699,8 +3054,11 @@ Extracted PRG files with metadata | Field | Type | Description | |-------|------|-------------| -| `prgs` | array | Array of PRG objects with name, load_address, and base64-encoded data | -| `disk_name` | string | Disk name (only for .d64 files) | +| `prgs` | array | Array of PRG file objects | +| `prgs[].name` | string | PRG file name | +| `prgs[].load_address` | integer | C64 load address (decimal) | +| `prgs[].data_b64` | string | Base64-encoded PRG content (load address header stripped) | +| `disk_name` | string | Disk name (only present for .d64 files) | **Error Responses** @@ -2737,7 +3095,12 @@ JSON object containing array of ZIP entries | Field | Type | Description | |-------|------|-------------| -| `entries` | array | Array of entry objects with path, name, and size | +| `entries` | array | Array of ZIP entry objects | +| `entries[].path` | string | Full path within the archive | +| `entries[].name` | string | File name (basename) | +| `entries[].size` | integer | Uncompressed file size in bytes | +| `entries[].comp_method` | string | Compression method (e.g., deflate, store) | +| `total` | integer | Total number of entries in the archive | **Error Responses** @@ -2812,8 +3175,12 @@ JSON object with archive metadata and entry list |-------|------|-------------| | `type` | string | Archive format code (e.g., 'zip', 'tar', 'rar') | | `label` | string | Human-readable archive type label | -| `entries` | array | Array of entry objects | -| `total` | integer | Total number of entries | +| `entries` | array | Array of archive entry objects | +| `entries[].path` | string | Full path within the archive | +| `entries[].name` | string | File name (basename) | +| `entries[].size` | integer | Uncompressed file size in bytes | +| `entries[].comp_method` | string | Compression method (present for ZIP entries only) | +| `total` | integer | Total number of entries in the archive | **Error Responses** @@ -2954,7 +3321,27 @@ Shared file metadata | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Query success status | -| `file` | object | File object with id, filename, size, and other metadata | +| `file` | object | File details | +| `file.id` | integer | File ID | +| `file.filename` | string | File name | +| `file.filesize` | integer | File size in bytes | +| `file.short_description` | string | Brief description | +| `file.long_description` | string | Extended description | +| `file.created_at` | string | Upload timestamp (ISO 8601) | +| `file.virus_scanned` | boolean | Whether virus scan was performed | +| `file.virus_scan_result` | string\|null | Scan result if scanned | +| `file.file_area_id` | integer | Associated file area ID | +| `file.area_tag` | string | Tag of the file area | +| `file.area_description` | string | Description of the file area | +| `file.domain` | string | Domain of the file area | +| `share_info` | object | Share metadata | +| `share_info.share_id` | integer | Share record ID | +| `share_info.shared_by` | string | Username of the user who created the share | +| `share_info.created_at` | string | Share creation timestamp (ISO 8601) | +| `share_info.expires_at` | string\|null | Share expiry timestamp; null for no expiry | +| `share_info.access_count` | integer | Number of times the share has been accessed | +| `share_info.share_url` | string | Full URL of the share link | +| `share_info.is_logged_in` | boolean | Whether the requesting user is authenticated | **Error Responses** @@ -3163,12 +3550,16 @@ JSON object with optional fields to update **Response** _(JSON)_ -Updated file metadata +Update confirmation with the fields that were changed | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Update successful | -| `file` | object | Updated file record | +| `filename` | string | New filename (only present if filename was updated) | +| `short_description` | string | New short description (only present if description was updated) | +| `long_description` | string\|null | New long description (only present if description was updated) | +| `file_area_id` | integer | New file area ID (only present if file was moved; admin only) | +| `url` | string | New URL (only present if URL was updated; admin only) | **Error Responses** @@ -3271,8 +3662,14 @@ Threaded comment messages | Field | Type | Description | |-------|------|-------------| | `enabled` | boolean | Whether comments are enabled for this file | -| `comments` | array | Array of comment objects with id, from_name, subject, message_text, date_written, reply_to_id | -| `total` | integer | Total comment count | +| `comments` | array | Threaded tree of top-level comment objects | +| `comments[].id` | integer | Echomail message ID | +| `comments[].from_name` | string | Name of the commenter | +| `comments[].date_written` | string | Message timestamp (ISO 8601) | +| `comments[].body` | string | Comment text (tearline stripped) | +| `comments[].level` | integer | Nesting depth (0 = top-level, max 2) | +| `comments[].children` | array | Nested reply objects (same structure, empty at level 2) | +| `total` | integer | Total comment count (flat, across all levels) | **Error Responses** @@ -3351,7 +3748,15 @@ Paginated frequency log entries | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Operation success indicator | -| `entries` | array | Log entries with id, requested_at, requesting_node, filename, served, deny_reason, file_size, source | +| `entries` | array | Log entries | +| `entries[].id` | integer | Log entry ID | +| `entries[].requested_at` | string | ISO 8601 timestamp of the request | +| `entries[].requesting_node` | string | FTN address of the requesting node | +| `entries[].filename` | string | Filename that was requested | +| `entries[].served` | boolean | Whether the file was served | +| `entries[].deny_reason` | string\|null | Reason for denial (if not served) | +| `entries[].file_size` | integer\|null | Size of the served file in bytes | +| `entries[].source` | string | Source of the file request | | `total` | integer | Total matching entries across all pages | | `page` | integer | Current page number | | `per_page` | integer | Entries per page (50) | @@ -3393,7 +3798,7 @@ Localized translation catalogs | `success` | boolean | Whether catalogs were successfully loaded | | `locale` | string | The resolved locale code used for translations | | `default_locale` | string | The system default locale | -| `catalogs` | object | Object mapping namespace names to their translation key-value pairs | +| `catalogs` | object | Object keyed by namespace name (e.g. `common`, `errors`); each value is an object mapping translation keys to their localized string values | **Error Responses** @@ -3428,8 +3833,16 @@ List of active interests | Field | Type | Description | |-------|------|-------------| | `interests` | array | Array of interest objects with subscription status | -| `id` | integer | Interest ID (nested in interests array) | -| `subscribed` | boolean | True if authenticated user is subscribed to this interest | +| `interests[].id` | integer | Interest ID | +| `interests[].name` | string | Interest name | +| `interests[].slug` | string | URL-safe slug | +| `interests[].description` | string\|null | Interest description | +| `interests[].sort_order` | integer | Display sort order | +| `interests[].is_active` | boolean | Whether the interest is active | +| `interests[].echoarea_count` | integer | Number of echo areas in this interest | +| `interests[].filearea_count` | integer | Number of file areas in this interest | +| `interests[].subscriber_count` | integer | Number of subscribers | +| `interests[].subscribed` | boolean | True if the authenticated user is subscribed | **Error Responses** @@ -3568,7 +3981,13 @@ List of echo areas in the interest | Field | Type | Description | |-------|------|-------------| -| `echoareas` | object[] | Array of echo area objects with fields: echoarea_id (int), tag (string), domain (string), description (string), message_count (int), subscribed (boolean, only if authenticated) | +| `echoareas` | object[] | Array of echo area objects | +| `echoareas[].echoarea_id` | integer | Echo area ID | +| `echoareas[].tag` | string | Echo area tag | +| `echoareas[].domain` | string | Network domain | +| `echoareas[].description` | string\|null | Echo area description | +| `echoareas[].message_count` | integer | Total message count | +| `echoareas[].subscribed` | boolean | _(authenticated only)_ Whether the user is subscribed to this area | **Error Responses** @@ -3600,7 +4019,13 @@ Aggregated message statistics | `recent` | integer | Messages from last 24 hours | | `unread` | integer | Unread messages for user | | `areas` | integer | Number of subscribed echo areas | -| `filter_counts` | object | Counts by filter: all, unread, read, tome, saved, drafts | +| `filter_counts` | object | Counts by filter | +| `filter_counts.all` | integer | Total messages | +| `filter_counts.unread` | integer | Unread messages | +| `filter_counts.read` | integer | Read messages | +| `filter_counts.tome` | integer | Messages addressed to me | +| `filter_counts.saved` | integer | Saved messages | +| `filter_counts.drafts` | integer | Draft messages | **Error Responses** @@ -3636,8 +4061,12 @@ Paginated message results | Field | Type | Description | |-------|------|-------------| -| `messages` | object[] | Array of echomail message objects | -| `pagination` | object | Pagination metadata (page, total_pages, total_count) | +| `messages` | object[] | Array of echomail message objects (same shape as `GET /api/messages/echomail`) | +| `pagination` | object | Pagination metadata | +| `pagination.page` | integer | Current page number | +| `pagination.limit` | integer | Messages per page | +| `pagination.total` | integer | Total matching messages | +| `pagination.pages` | integer | Total number of pages | **Error Responses** @@ -3667,7 +4096,10 @@ JSON object containing success flag and array of image objects. | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always true on success | -| `images` | array | Array of image objects with filename, url, and created_at | +| `images` | array | Array of image objects | +| `images[].filename` | string | Original filename | +| `images[].url` | string | Public URL for embedding in markdown | +| `images[].created_at` | string | ISO 8601 upload timestamp | **Error Responses** @@ -3729,9 +4161,16 @@ Fetches and streams audio/music files (XM, IT, S3M, MOD, SID, MIDI, etc.) from e |------|------|----------|-------------| | `url` | string | Yes | Public HTTP(S) URL to media file with allowed extension | -**Response** _(JSON)_ +**Response** _(binary)_ + +Raw proxied media file bytes -Raw media file stream with appropriate Content-Type header. +| Header | Value | +|--------|-------| +| `Content-Type` | MIME type reported by the upstream server (e.g. `audio/x-mod`, `application/octet-stream`) | +| `Content-Length` | File size in bytes | +| `Content-Disposition` | `inline; filename=""` | +| `Cache-Control` | `public, max-age=86400` | **Error Responses** @@ -3927,7 +4366,14 @@ JSON object containing array of recent messages | Field | Type | Description | |-------|------|-------------| -| `messages` | array of objects | Array of message objects with id, type (netmail/echomail), from_name, subject, date_written, echoarea (null for netmail), and echoarea_color (null for netmail) | +| `messages` | array of objects | Array of recent message objects | +| `messages[].id` | integer | Message ID | +| `messages[].type` | string | Message type: `netmail` or `echomail` | +| `messages[].from_name` | string | Sender display name | +| `messages[].subject` | string | Message subject | +| `messages[].date_written` | string | ISO 8601 date the message was composed | +| `messages[].echoarea` | string\|null | Echo area tag (null for netmail) | +| `messages[].echoarea_color` | string\|null | Echo area display colour (null for netmail) | --- @@ -3952,9 +4398,36 @@ Paginated netmail message list | Field | Type | Description | |-------|------|-------------| -| `messages` | array | Array of netmail message objects | -| `page` | integer | Current page number | -| `total` | integer | Total message count | +| `messages` | array | Array of netmail message objects (see shape below) | +| `pagination.page` | integer | Current page number | +| `pagination.limit` | integer | Messages per page (from user setting, default 25) | +| `pagination.total` | integer | Total message count matching the current filter | +| `pagination.pages` | integer | Total number of pages | + +**Netmail object** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | integer | Message ID | +| `from_name` | string | Sender display name (UTF-8 normalized) | +| `from_address` | string | Sender FidoNet address (e.g. `1:123/456`) | +| `to_name` | string | Recipient display name | +| `to_address` | string | Recipient FidoNet address | +| `subject` | string | Message subject; masked as `"••••••••"` for AreaFix/FileFix robot messages | +| `date_received` | string | UTC timestamp when the message was stored server-side — reliable for display and sorting | +| `date_written` | string | Timestamp from the FTN packet header — reflects when the sender composed the message; may be wrong or in the future if the remote clock is incorrect | +| `user_id` | integer | ID of the local user who owns (sent or received) this message | +| `attributes` | integer | FTN message attribute bitmask (FTS-0001) | +| `is_sent` | boolean | True if this message was sent by the local system | +| `is_freq` | boolean | True if this is a file-request message | +| `reply_to_id` | integer\|null | ID of the message this is a reply to, or null | +| `is_read` | integer | `1` if the authenticated user has read this message, `0` otherwise | +| `has_attachment` | integer | `1` if one or more file attachments exist for this message, `0` otherwise | +| `is_saved` | integer | `1` if the authenticated user has saved this message, `0` otherwise | +| `replyto_address` | string\|null | FidoNet address parsed from the `REPLYTO` kludge, if present | +| `replyto_name` | string\|null | Recipient name parsed from the `REPLYTO` kludge, if present | +| `from_domain` | string\|null | FTN domain name resolved from `from_address`, or null if unresolvable | +| `to_domain` | string\|null | FTN domain name resolved from `to_address`, or null if unresolvable | --- @@ -3998,7 +4471,17 @@ Complete netmail message with metadata | `kludge_lines` | string | FidoNet kludge lines | | `replyto_address` | string | Parsed REPLYTO FidoNet address | | `replyto_name` | string | Parsed REPLYTO recipient name | -| `attachments` | array | File attachments (empty array if feature disabled) | +| `attachments` | array of objects | File attachments (empty array if feature disabled) | +| `attachments[].id` | integer | File record ID | +| `attachments[].filename` | string | Original filename | +| `attachments[].filesize` | integer | File size in bytes | +| `attachments[].short_description` | string\|null | Short file description | +| `attachments[].long_description` | string\|null | Extended file description | +| `attachments[].source_type` | string | Origin: `netmail_attachment`, `user`, or `fidonet` | +| `attachments[].status` | string | Approval status: `approved`, `pending`, `rejected`, or `quarantined` | +| `attachments[].created_at` | string | UTC timestamp when the file was stored | +| `attachments[].area_tag` | string | File area tag the attachment belongs to | +| `attachments[].is_private` | boolean | True if the file area is private | | `can_edit` | boolean | Whether current user can edit this message | **Error Responses** @@ -4023,11 +4506,17 @@ Fetches all messages in a conversation thread anchored by the specified message **Response** _(JSON)_ -Conversation thread containing the message +Full conversation thread, flattened in display order | Field | Type | Description | |-------|------|-------------| -| `messages` | array | Array of messages in the conversation thread | +| `messages` | array | Netmail message objects in the thread, flattened for display (same shape as the list endpoint, without `is_saved`) | +| `unreadCount` | integer | Number of unread messages in this thread | +| `threaded` | boolean | Always `true` for this endpoint | +| `pagination.page` | integer | Always `1` (full thread is returned) | +| `pagination.limit` | integer | Number of messages returned | +| `pagination.total` | integer | Total messages in the thread | +| `pagination.pages` | integer | Always `1` | **Error Responses** @@ -4216,9 +4705,37 @@ Paginated echomail message list | Field | Type | Description | |-------|------|-------------| -| `messages` | array | Array of echomail message objects | -| `page` | integer | Current page number | -| `total` | integer | Total message count | +| `messages` | array | Array of echomail message objects (see shape below) | +| `unreadCount` | integer | Total unread messages across all subscribed areas matching the current filter | +| `pagination.page` | integer | Current page number | +| `pagination.limit` | integer | Messages per page (from user setting, default 25) | +| `pagination.total` | integer | Total message count matching the current filter | +| `pagination.pages` | integer | Total number of pages | +| `info` | string | _(optional)_ Human-readable notice when the user has no subscriptions | + +**Echomail object** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | integer | Message ID | +| `from_name` | string | Sender display name (UTF-8 normalized) | +| `from_address` | string | Sender FidoNet address (e.g. `1:123/456`) | +| `to_name` | string | Recipient display name (often `"All"` for public posts) | +| `subject` | string | Message subject | +| `date_received` | string | UTC timestamp when the message was stored server-side — reliable for display and sorting | +| `date_written` | string | Timestamp from the FTN packet header — reflects when the sender composed the message; may be wrong or in the future if the remote clock is incorrect | +| `echoarea_id` | integer | ID of the echo area this message belongs to | +| `echoarea` | string | Echo area tag (e.g. `"FIDONEWS"`) | +| `echoarea_color` | string | Hex color code configured for this echo area (e.g. `"#28a745"`) | +| `echoarea_domain` | string | Domain of the echo area (e.g. `"lovlynet"`) | +| `message_id` | string | FTN Message-ID kludge value from the original packet | +| `reply_to_id` | integer\|null | ID of the message this is a reply to, or null | +| `art_format` | string\|null | Art format hint: `"ansi"`, `"amiga_ansi"`, `"petscii"`, or null (message-level value takes precedence over area default) | +| `is_read` | integer | `1` if the authenticated user has read this message, `0` otherwise | +| `is_shared` | integer | `1` if an active share link exists for this message, `0` otherwise | +| `is_saved` | integer | `1` if the authenticated user has saved this message, `0` otherwise | +| `replyto_address` | string\|null | FidoNet address parsed from the `REPLYTO` kludge, if present | +| `replyto_name` | string\|null | Recipient name parsed from the `REPLYTO` kludge, if present | **Error Responses** @@ -4315,7 +4832,8 @@ Rule creation confirmation with localization |-------|------|-------------| | `success` | boolean | Rule saved successfully | | `message_code` | string | Localization key for UI message | -| `message_params` | object | Localization parameters (sender name) | +| `message_params` | object | Localization parameters for message_code | +| `message_params.sender_name` | string | Sender name from the saved ignore rule | **Error Responses** @@ -4338,7 +4856,17 @@ Aggregate echomail statistics object | Field | Type | Description | |-------|------|-------------| -| `areas` | array | Array of statistics per echo area | +| `total` | integer | Total echomail message count across all subscribed areas | +| `recent` | integer | Messages received in the last 24 hours | +| `unread` | integer | Unread message count across all subscribed areas | +| `areas` | integer\|null | Number of subscribed echo areas, or null for single-area queries | +| `filter_counts` | object | Message counts broken down by filter type | +| `filter_counts.all` | integer | Total message count | +| `filter_counts.unread` | integer | Unread message count | +| `filter_counts.read` | integer | Read message count | +| `filter_counts.tome` | integer | Messages addressed to the current user | +| `filter_counts.saved` | integer | Saved message count | +| `filter_counts.drafts` | integer | Echomail draft count | --- @@ -4414,11 +4942,17 @@ Retrieves the full conversation thread containing the specified message, includi **Response** _(JSON)_ -Conversation thread data +Full conversation thread, flattened in display order | Field | Type | Description | |-------|------|-------------| -| `messages` | array | Array of messages in the conversation thread | +| `messages` | array | Echomail message objects in the thread, flattened for display (same shape as the list endpoint, without `replyto_address` / `replyto_name`) | +| `unreadCount` | integer | Number of unread messages in this thread | +| `threaded` | boolean | Always `true` for this endpoint | +| `pagination.page` | integer | Always `1` (full thread is returned) | +| `pagination.limit` | integer | Number of messages returned | +| `pagination.total` | integer | Total messages in the thread | +| `pagination.pages` | integer | Always `1` | **Error Responses** @@ -4553,7 +5087,7 @@ Paginated list of echomail messages with metadata | Field | Type | Description | |-------|------|-------------| -| `messages` | array | Array of message objects with headers, body, and metadata | +| `messages` | array | Array of echomail message objects (see **Echomail object** in `GET /api/messages/echomail`) | | `page` | integer | Current page number | | `total_pages` | integer | Total number of pages available | @@ -4605,7 +5139,7 @@ Complete echomail message object **Requires authentication** -Sends a message (netmail or echomail) with support for multiple charsets, markdown/plaintext markup, and file attachments. Enforces 16 KB FidoNet message body limit. For netmail, resolves attachment tokens to file paths. Supports crashmail flag and file request (FREQ) mode. Validates charset against a whitelist of safe values. Defaults to system address if no recipient specified for netmail. +Sends a message (netmail or echomail) with support for multiple charsets, markdown/plaintext markup, file attachments, and optional PGP payload handling. Enforces 16 KB FidoNet message body limit. For netmail, resolves attachment tokens to file paths. Supports crashmail flag and file request (FREQ) mode. Validates charset against a whitelist of safe values. Defaults to system address if no recipient specified for netmail. **Request Body** _(JSON)_ @@ -4621,6 +5155,7 @@ Message composition payload | `attachment_token` | string | No | 32-character hex token from attachment upload endpoint | | `crashmail` | boolean | No | Send as crashmail (netmail only) | | `is_freq` | boolean | No | Mark as file request (netmail only) | +| `pgp_mode` | string | No | PGP handling mode: `encrypt` for netmail encryption or `sign` for echomail signing | **Response** _(JSON)_ @@ -4750,7 +5285,18 @@ Array of draft objects with metadata. | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Indicates successful retrieval. | -| `drafts` | array | List of draft message objects. | +| `drafts` | array of objects | List of draft message objects. | +| `drafts[].id` | integer | Draft ID. | +| `drafts[].type` | string | Draft type: `netmail` or `echomail`. | +| `drafts[].to_address` | string\|null | FidoNet address of the recipient (netmail only). | +| `drafts[].to_name` | string\|null | Recipient display name. | +| `drafts[].echoarea` | string\|null | Echo area tag (echomail only). | +| `drafts[].subject` | string\|null | Message subject. | +| `drafts[].message_text` | string\|null | Draft message body. | +| `drafts[].reply_to_id` | integer\|null | ID of the message this draft replies to, or null. | +| `drafts[].created_at` | string | UTC timestamp when the draft was created. | +| `drafts[].updated_at` | string | UTC timestamp of the last update. | +| `drafts[].meta` | object\|null | Additional metadata (e.g. cross-post area list), or null. | **Error Responses** @@ -4780,6 +5326,17 @@ Single draft object with full content. |-------|------|-------------| | `success` | boolean | Indicates successful retrieval. | | `draft` | object | Complete draft message object. | +| `draft.id` | integer | Draft ID. | +| `draft.type` | string | Draft type: `netmail` or `echomail`. | +| `draft.to_address` | string\|null | FidoNet address of the recipient (netmail only). | +| `draft.to_name` | string\|null | Recipient display name. | +| `draft.echoarea` | string\|null | Echo area tag (echomail only). | +| `draft.subject` | string\|null | Message subject. | +| `draft.message_text` | string\|null | Draft message body. | +| `draft.reply_to_id` | integer\|null | ID of the message this draft replies to, or null. | +| `draft.created_at` | string | UTC timestamp when the draft was created. | +| `draft.updated_at` | string | UTC timestamp of the last update. | +| `draft.meta` | object\|null | Additional metadata (e.g. cross-post area list), or null. | **Error Responses** @@ -4837,7 +5394,12 @@ Array of template metadata objects. | Field | Type | Description | |-------|------|-------------| -| `templates` | array | List of template objects with id, name, type, subject, created_at. | +| `templates` | array of objects | List of template metadata objects. | +| `templates[].id` | integer | Template ID. | +| `templates[].name` | string | Template name (up to 100 characters). | +| `templates[].type` | string | Template type: `netmail`, `echomail`, or `both`. | +| `templates[].subject` | string | Template subject line. | +| `templates[].created_at` | string | UTC timestamp when the template was created. | **Error Responses** @@ -4865,7 +5427,14 @@ Complete template object with all fields. | Field | Type | Description | |-------|------|-------------| -| `template` | object | Template object including id, name, type, subject, body, created_at, updated_at. | +| `template` | object | Complete template object. | +| `template.id` | integer | Template ID. | +| `template.name` | string | Template name (up to 100 characters). | +| `template.type` | string | Template type: `netmail`, `echomail`, or `both`. | +| `template.subject` | string | Template subject line. | +| `template.body` | string | Template message body. | +| `template.created_at` | string | UTC timestamp when the template was created. | +| `template.updated_at` | string | UTC timestamp of the last modification. | **Error Responses** @@ -4966,12 +5535,32 @@ Searches messages across drafts, netmail, and echomail. Supports general query ( **Response** _(JSON)_ -Paginated search results with message metadata. +Search results with message metadata and per-area counts. | Field | Type | Description | |-------|------|-------------| -| `results` | array | Array of matching message objects. | -| `total` | integer | Total number of matching messages. | +| `messages` | array | Array of matching message objects | +| `messages[].id` | integer | Message ID | +| `messages[].from_name` | string | Sender name | +| `messages[].from_address` | string | Sender FTN address | +| `messages[].to_name` | string | Recipient name | +| `messages[].subject` | string | Message subject | +| `messages[].date_received` | string | Server receipt timestamp (ISO 8601) | +| `messages[].date_written` | string | Sender-written timestamp (ISO 8601) | +| `messages[].echoarea_id` | integer\|null | Echo area ID (echomail only) | +| `messages[].echoarea` | string\|null | Echo area tag (echomail only) | +| `messages[].echoarea_domain` | string\|null | Echo area domain (echomail only) | +| `echoarea_counts` | array | Per-area message counts (echomail searches only; empty for netmail/draft) | +| `echoarea_counts[].tag` | string | Echo area tag | +| `echoarea_counts[].domain` | string | Echo area domain | +| `echoarea_counts[].message_count` | integer | Number of matching messages in this area | +| `filter_counts` | object | Counts by read/saved status across all results (echomail only) | +| `filter_counts.all` | integer | Total matches | +| `filter_counts.unread` | integer | Unread matches | +| `filter_counts.read` | integer | Read matches | +| `filter_counts.tome` | integer | Messages addressed to the current user | +| `filter_counts.saved` | integer | Saved matches | +| `filter_counts.drafts` | integer | Always 0 (reserved) | **Error Responses** @@ -5175,7 +5764,29 @@ Retrieves all active share links for a specific echomail message. Requires authe **Response** _(JSON)_ -Array of share links with metadata +Share links for this message, split by ownership + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `my_shares` | array | Share links created by the authenticated user | +| `my_shares[].share_key` | string | Unique share token used in the share URL | +| `my_shares[].share_url` | string | Full share URL (friendly URL if slug is set, otherwise key-based) | +| `my_shares[].has_friendly_url` | boolean | True if a slug-based friendly URL is available | +| `my_shares[].created_at` | string | ISO 8601 timestamp when the share was created | +| `my_shares[].expires_at` | string\|null | ISO 8601 expiry timestamp, or null if non-expiring | +| `my_shares[].is_public` | boolean | Whether the share is publicly accessible | +| `my_shares[].access_count` | integer | Number of times the share URL has been accessed | +| `my_shares[].last_accessed_at` | string\|null | ISO 8601 timestamp of last access, or null | +| `my_shares[].og_image_path` | string\|null | Server path to custom OG preview image, or null | +| `my_shares[].og_image_slug` | string\|null | Slug for the OG image URL, or null | +| `my_shares[].top_referrers` | array | Reserved; currently always empty | +| `other_shares` | array | Active public share links for this message created by other users | +| `other_shares[].share_url` | string | Full share URL | +| `other_shares[].shared_by_username` | string | Username of the user who created the share | +| `other_shares[].created_at` | string | ISO 8601 timestamp when the share was created | +| `other_shares[].is_public` | boolean | Whether the share is publicly accessible | +| `other_shares[].top_referrers` | array | Reserved; currently always empty | **Error Responses** @@ -5358,7 +5969,29 @@ Shared message data | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether the message was successfully retrieved | -| `message` | object | The shared message content and metadata | +| `message` | object | The echomail or netmail message record | +| `message.id` | integer | Message ID | +| `message.from_name` | string | Sender name | +| `message.to_name` | string | Recipient name | +| `message.subject` | string | Message subject | +| `message.message_text` | string | Message body | +| `message.date_written` | string | Sender-written timestamp (ISO 8601) | +| `message.date_received` | string | Server receipt timestamp (ISO 8601) | +| `message.echoarea` | string\|null | Echo area tag (echomail only) | +| `message.echoarea_color` | string\|null | Echo area color (echomail only) | +| `message.echoarea_domain` | string\|null | Echo area domain (echomail only) | +| `message.from_system_name` | string\|null | Nodelist system name for sender address | +| `share_info` | object | Share metadata | +| `share_info.id` | integer | Share record ID | +| `share_info.share_key` | string | Share token string | +| `share_info.shared_by` | string | Real name (or username) of the user who shared the message | +| `share_info.created_at` | string | Share creation timestamp (ISO 8601) | +| `share_info.expires_at` | string\|null | Share expiry timestamp; null for no expiry | +| `share_info.is_public` | boolean | Whether share is publicly accessible | +| `share_info.access_count` | integer | Number of times the share has been accessed | +| `share_info.ai_og_summary` | string\|null | AI-generated OpenGraph summary if provided | +| `share_info.og_image_path` | string\|null | Server path to OpenGraph preview image | +| `share_info.og_image_slug` | string\|null | URL slug for OpenGraph preview image | **Error Responses** @@ -5389,7 +6022,29 @@ Shared message data | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether the message was successfully retrieved | -| `message` | object | The shared message content and metadata | +| `message` | object | The echomail or netmail message record | +| `message.id` | integer | Message ID | +| `message.from_name` | string | Sender name | +| `message.to_name` | string | Recipient name | +| `message.subject` | string | Message subject | +| `message.message_text` | string | Message body | +| `message.date_written` | string | Sender-written timestamp (ISO 8601) | +| `message.date_received` | string | Server receipt timestamp (ISO 8601) | +| `message.echoarea` | string\|null | Echo area tag (echomail only) | +| `message.echoarea_color` | string\|null | Echo area color (echomail only) | +| `message.echoarea_domain` | string\|null | Echo area domain (echomail only) | +| `message.from_system_name` | string\|null | Nodelist system name for sender address | +| `share_info` | object | Share metadata | +| `share_info.id` | integer | Share record ID | +| `share_info.share_key` | string | Share token string | +| `share_info.shared_by` | string | Real name (or username) of the user who shared the message | +| `share_info.created_at` | string | Share creation timestamp (ISO 8601) | +| `share_info.expires_at` | string\|null | Share expiry timestamp; null for no expiry | +| `share_info.is_public` | boolean | Whether share is publicly accessible | +| `share_info.access_count` | integer | Number of times the share has been accessed | +| `share_info.ai_og_summary` | string\|null | AI-generated OpenGraph summary if provided | +| `share_info.og_image_path` | string\|null | Server path to OpenGraph preview image | +| `share_info.og_image_slug` | string\|null | URL slug for OpenGraph preview image | **Error Responses** @@ -5419,7 +6074,14 @@ AI assistance request **Response** _(JSON)_ -AI-generated response (truncated in snippet) +AI assistant result with the generated reply and resulting credit information. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True when the request completed successfully | +| `response` | string | AI-generated reply text | +| `credits_used` | integer | Credits charged for this AI request | +| `balance` | integer | User's remaining credit balance after the request | **Error Responses** @@ -5428,6 +6090,7 @@ AI-generated response (truncated in snippet) | 403 | AI assistant is disabled on this system | | 503 | AI assistant not configured (missing API keys) | | 400 | Prompt is empty or exceeds 500 character limit | +| 402 | Insufficient credits for the AI request | --- @@ -5497,7 +6160,11 @@ Nodelist entry or null if not found | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always true; check node field for null | -| `node` | object|null | Node object with address, system_name, location, domain; null if not found | +| `node` | object\|null | Node object, or null if address not found | +| `node.address` | string | Full FTN node address | +| `node.system_name` | string | System name from nodelist | +| `node.location` | string | Node location | +| `node.domain` | string | Network domain | **Error Responses** @@ -5526,7 +6193,12 @@ JSON object containing search results | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always true on successful request | -| `nodes` | array | Array of matching nodes (max 10 items), each with address, system_name, location, and domain | +| `nodes` | array | Array of matching nodes (max 10 items) | +| `nodes[].address` | string | FTN node address | +| `nodes[].system_name` | string | Node system name | +| `nodes[].sysop_name` | string | Sysop name | +| `nodes[].location` | string | Node location | +| `nodes[].domain` | string | Network domain | **Error Responses** @@ -5556,7 +6228,17 @@ Notification state object | Field | Type | Description | |-------|------|-------------| -| `state` | object | Notification state with mailLastCounts, mailUnread, chatLastTotal, chatUnread, filesLastMaxId, filesUnread | +| `state` | object | Notification state | +| `state.mailLastCounts` | object | Last-seen mail counts | +| `state.mailLastCounts.netmail` | integer | Last-seen netmail count | +| `state.mailLastCounts.echomail` | integer | Last-seen echomail max ID | +| `state.mailUnread` | object | Unread mail flags | +| `state.mailUnread.netmail` | boolean | Whether netmail has unread messages | +| `state.mailUnread.echomail` | boolean | Whether echomail has unread messages | +| `state.chatLastTotal` | integer | Last-seen chat message ID | +| `state.chatUnread` | boolean | Whether chat has unread messages | +| `state.filesLastMaxId` | integer | Last-seen file max ID | +| `state.filesUnread` | boolean | Whether there are new files | **Error Responses** @@ -5588,6 +6270,16 @@ Update confirmation with normalized state |-------|------|-------------| | `success` | boolean | Whether state was saved | | `state` | object | Normalized state as stored | +| `state.mailLastCounts` | object | Last-seen mail counts | +| `state.mailLastCounts.netmail` | integer | Last-seen netmail count | +| `state.mailLastCounts.echomail` | integer | Last-seen echomail max ID | +| `state.mailUnread` | object | Unread mail flags | +| `state.mailUnread.netmail` | boolean | Whether netmail has unread messages | +| `state.mailUnread.echomail` | boolean | Whether echomail has unread messages | +| `state.chatLastTotal` | integer | Last-seen chat message ID | +| `state.chatUnread` | boolean | Whether chat has unread messages | +| `state.filesLastMaxId` | integer | Last-seen file max ID | +| `state.filesUnread` | boolean | Whether there are new files | **Error Responses** @@ -5653,7 +6345,20 @@ List of pending user registrations | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True on success | -| `users` | array | Array of pending user objects | +| `users` | array | Array of pending user registration objects | +| `users[].id` | integer | Pending user record ID | +| `users[].username` | string | Requested username | +| `users[].email` | string\|null | Email address provided during registration | +| `users[].real_name` | string\|null | Real name provided during registration | +| `users[].reason` | string\|null | Reason for registration (if required) | +| `users[].requested_at` | string | Registration request timestamp (ISO 8601) | +| `users[].ip_address` | string\|null | IP address at time of registration | +| `users[].status` | string | Current status (pending, approved, rejected) | +| `users[].reviewed_by` | integer\|null | User ID of admin who reviewed | +| `users[].reviewed_at` | string\|null | Review timestamp (ISO 8601) | +| `users[].admin_notes` | string\|null | Admin notes on the registration | +| `users[].reviewed_by_username` | string\|null | Username of reviewing admin | +| `users[].registration_source` | string | Registration source (web, terminal, etc.) | **Error Responses** @@ -5684,7 +6389,19 @@ Pending user registration details | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True on success | -| `user` | object | Pending user record with referrer details | +| `user` | object | Pending user registration details | +| `user.id` | integer | Pending user record ID | +| `user.username` | string | Requested username | +| `user.email` | string\|null | Email address | +| `user.real_name` | string\|null | Real name | +| `user.reason` | string\|null | Registration reason | +| `user.requested_at` | string | Registration request timestamp (ISO 8601) | +| `user.ip_address` | string\|null | IP address at registration | +| `user.status` | string | Current status (pending, approved, rejected) | +| `user.admin_notes` | string\|null | Admin notes | +| `user.referrer_id` | integer\|null | User ID of the referrer | +| `user.referrer_username` | string\|null | Username of the referrer | +| `user.referrer_real_name` | string\|null | Real name of the referrer | **Error Responses** @@ -5796,7 +6513,18 @@ JSON object containing array of active polls | Field | Type | Description | |-------|------|-------------| -| `polls` | array of objects | Array of poll objects with id, question, options array, and has_voted boolean | +| `polls` | array of objects | Array of poll objects. Unvoted polls come first, then voted polls. | +| `polls[].id` | integer | Poll ID | +| `polls[].question` | string | Poll question text | +| `polls[].options` | array | Answer options | +| `polls[].options[].id` | integer | Option ID | +| `polls[].options[].option_text` | string | Option display text | +| `polls[].has_voted` | boolean | Whether the authenticated user has voted on this poll | +| `polls[].results` | array | _(present only when `has_voted` is true)_ Per-option vote counts | +| `polls[].results[].option_id` | integer | Option ID | +| `polls[].results[].option_text` | string | Option display text | +| `polls[].results[].votes` | integer | Vote count for this option | +| `polls[].total_votes` | integer | _(present only when `has_voted` is true)_ Total votes cast | --- @@ -5903,7 +6631,7 @@ Import result with message statistics | `success` | boolean | Whether processing completed successfully | | `imported` | integer | Number of messages imported from the packet | | `skipped` | integer | Number of messages skipped (duplicates, errors) | -| `errors` | array | Array of error messages encountered during processing | +| `errors` | array of strings | Error messages encountered during processing; empty array if none | **Error Responses** @@ -5927,10 +6655,18 @@ QWK status with subscriptions and message counts | Field | Type | Description | |-------|------|-------------| -| `has_custom_areas` | boolean | Whether user has custom area selection active | -| `areas` | array | Array of subscribed/selected echoareas with message counts | -| `netmail_count` | integer | Number of new netmail messages | -| `total_new` | integer | Total new messages across all areas | +| `total_new_messages` | integer | Total new messages across all conferences | +| `last_download` | string\|null | Timestamp of the last packet download (ISO 8601); null if never downloaded | +| `conferences` | array | List of QWK conference objects | +| `conferences[].number` | integer | QWK conference number (0 = Personal Mail) | +| `conferences[].name` | string | Conference name (echoarea tag or 'Personal Mail') | +| `conferences[].is_netmail` | boolean | Whether this conference is the personal netmail conference | +| `conferences[].new_messages` | integer | Number of new messages in this conference | +| `format` | string | User's preferred packet format ('qwk' or 'qwke') | +| `limit` | integer | Maximum messages per packet (user-configurable) | +| `hard_cap` | integer | System-wide maximum messages per packet | +| `is_dev` | boolean | Whether the system is in development mode | +| `has_custom_selection` | boolean | Whether user has a custom area selection active | **Error Responses** @@ -6010,7 +6746,15 @@ Area selection state and available areas |-------|------|-------------| | `has_custom` | boolean | True if user has an explicit custom selection active | | `selections` | array | Currently selected areas (empty if has_custom is false) | -| `subscribed` | array | All areas user is subscribed to | +| `selections[].id` | integer | Echo area ID | +| `selections[].tag` | string | Echo area tag | +| `selections[].domain` | string | Echo area domain | +| `selections[].description` | string | Echo area description | +| `subscribed` | array | All echo areas the user is subscribed to | +| `subscribed[].id` | integer | Echo area ID | +| `subscribed[].tag` | string | Echo area tag | +| `subscribed[].domain` | string | Echo area domain | +| `subscribed[].description` | string | Echo area description | **Error Responses** @@ -6042,7 +6786,7 @@ Confirmation of saved selection | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True if selection was saved | -| `selections` | array | The validated and saved area IDs | +| `count` | integer\|null | Number of areas saved; null if reset=true | **Error Responses** @@ -6071,7 +6815,11 @@ Search results | Field | Type | Description | |-------|------|-------------| -| `areas` | array | Matching echoareas (up to 20 results) | +| `areas` | array | Matching echo area objects (up to 20 results) | +| `areas[].id` | integer | Echo area ID | +| `areas[].tag` | string | Echo area tag | +| `areas[].domain` | string | Echo area domain | +| `areas[].description` | string | Echo area description | **Error Responses** @@ -6102,7 +6850,10 @@ User's referral statistics and earnings |-------|------|-------------| | `referral_code` | string | Unique referral code for this user | | `referral_url` | string | Full URL for sharing referral link | -| `referrals` | array | List of referred users with username, real_name, and created_at | +| `referrals` | array | List of users referred by this user | +| `referrals[].username` | string | Username of the referred user | +| `referrals[].real_name` | string | Real name of the referred user | +| `referrals[].created_at` | string | Account creation timestamp (ISO 8601) | | `total_count` | integer | Total number of users referred | | `total_earned` | integer | Total credits earned from referral bonuses | | `referral_bonus` | integer | Credits awarded per successful referral | @@ -6129,8 +6880,14 @@ System-wide referral statistics | Field | Type | Description | |-------|------|-------------| | `total_referrals` | integer | Total number of users referred across system | -| `top_referrers` | array | Top 10 referrers with username, real_name, and referral_count | -| `recent_referrals` | array | 10 most recent referrals with username, created_at, and referrer username | +| `top_referrers` | array | Top 10 referrers | +| `top_referrers[].username` | string | Referrer's username | +| `top_referrers[].real_name` | string | Referrer's real name | +| `top_referrers[].referral_count` | integer | Number of successful referrals | +| `recent_referrals` | array | 10 most recent referral signups | +| `recent_referrals[].username` | string | Referred user's username | +| `recent_referrals[].created_at` | string | Account creation timestamp (ISO 8601) | +| `recent_referrals[].referrer` | string | Username of the referrer | | `total_credits_awarded` | integer | Total credits distributed as referral bonuses | **Error Responses** @@ -6210,7 +6967,11 @@ JSON object containing array of shoutbox messages | Field | Type | Description | |-------|------|-------------| -| `messages` | array of objects | Array of message objects with id, message text, created_at timestamp, and username | +| `messages` | array of objects | Array of shoutbox message objects | +| `messages[].id` | integer | Message ID | +| `messages[].message` | string | Message text | +| `messages[].created_at` | string | ISO 8601 creation timestamp | +| `messages[].username` | string | Username of the poster | --- @@ -6313,57 +7074,131 @@ Command execution result #### `GET /api/subscriptions/user` -Public +**Requires authentication** -Fetches subscription data for the authenticated user. Delegates to SubscriptionController for handling. Exact response structure depends on controller implementation. +Fetches all active echo areas with the authenticated user's subscription status for each. **Response** _(JSON)_ -User subscription details (structure determined by SubscriptionController) +Echo areas with per-user subscription state + +| Field | Type | Description | +|-------|------|-------------| +| `echoareas` | array | All active echo areas visible to the user | +| `echoareas[].id` | integer | Echo area ID | +| `echoareas[].tag` | string | Echo area tag | +| `echoareas[].description` | string | Human-readable description | +| `echoareas[].domain` | string | Domain (e.g. `"lovlynet"`) | +| `echoareas[].is_local` | boolean | Whether the area is local-only | +| `echoareas[].is_sysop_only` | boolean | Whether the area is restricted to sysops | +| `echoareas[].is_default_subscription` | boolean | Whether new users are auto-subscribed | +| `echoareas[].is_new` | boolean | True if the area was created in the last 30 days | +| `echoareas[].subscribed` | boolean\|null | True if the user has an active subscription, null if never subscribed | +| `echoareas[].subscription_type` | string\|null | `"user"` (manually subscribed) or `"auto"` (default subscription), null if not subscribed | +| `echoareas[].subscribed_at` | string\|null | ISO 8601 timestamp when the user subscribed, null if not subscribed | --- #### `POST /api/subscriptions/user` -Public +**Requires authentication** -Manages user subscription creation or modification. Delegates to SubscriptionController for handling. Exact request/response structure depends on controller implementation. +Subscribe or unsubscribe the authenticated user from an echo area. **Request Body** _(JSON)_ -Subscription data (structure determined by SubscriptionController) +Subscription action + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | Yes | Either `"subscribe"` or `"unsubscribe"` | +| `echoarea_id` | integer | Yes | Echo area to act on | **Response** _(JSON)_ -Subscription operation result (structure determined by SubscriptionController) +Subscription action result + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the action was applied | +| `message_code` | string | Localization key for UI message (present on success; e.g. `"ui.user_subscriptions.subscribed_success"`) | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Missing echoarea_id or invalid action | +| 401 | Authentication required | --- #### `GET /api/subscriptions/admin` -Public +**Requires authentication** -Fetches system-wide subscription data for administrative review. Delegates to SubscriptionController for handling. Exact response structure depends on controller implementation. +Fetches all active echo areas with subscriber statistics and system-wide subscription totals. Requires admin privileges. **Response** _(JSON)_ -Admin subscription statistics (structure determined by SubscriptionController) +Echo area subscription statistics + +| Field | Type | Description | +|-------|------|-------------| +| `echoareas` | array | Active echo areas with subscriber counts | +| `echoareas[].id` | integer | Echo area ID | +| `echoareas[].tag` | string | Echo area tag | +| `echoareas[].description` | string | Description | +| `echoareas[].is_default_subscription` | boolean | Whether the area is a default subscription | +| `echoareas[].subscriber_count` | integer | Total active subscribers | +| `echoareas[].user_subscribers` | integer | Subscribers with type `"user"` (manually subscribed) | +| `echoareas[].auto_subscribers` | integer | Subscribers with type `"auto"` (default subscription) | +| `stats` | object | System-wide subscription totals | +| `stats.total_echoareas` | integer | Total active echo areas | +| `stats.default_echoareas` | integer | Echo areas marked as default subscriptions | +| `stats.total_subscriptions` | integer | Total active subscriptions across all users | +| `stats.subscribed_users` | integer | Number of distinct users with at least one active subscription | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 401 | Authentication required | +| 403 | Admin access required | --- #### `POST /api/subscriptions/admin` -Public +**Requires authentication** -Allows administrators to create or modify subscription configurations. Delegates to SubscriptionController for handling. Exact request/response structure depends on controller implementation. +Update administrative subscription settings for an echo area. Requires admin privileges. **Request Body** _(JSON)_ -Subscription configuration data (structure determined by SubscriptionController) +Admin subscription action + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | Yes | Currently supported: `"set_default"` | +| `echoarea_id` | integer | Yes | Echo area to update | +| `is_default` | boolean | No | Required for `set_default`; true to mark as default, false to unmark | **Response** _(JSON)_ -Subscription management result (structure determined by SubscriptionController) +Admin action result + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the action was applied | +| `message_code` | string | Localization key for UI message (present on success; e.g. `"ui.admin_subscriptions.default_enabled_success"`) | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Missing echoarea_id or invalid action | +| 401 | Authentication required | +| 403 | Admin access required | --- @@ -6415,7 +7250,7 @@ List of available taglines | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether taglines were successfully loaded | -| `taglines` | array | Array of tagline strings | +| `taglines` | array of strings | Plain text tagline strings, one per entry | **Error Responses** @@ -6509,6 +7344,13 @@ Open Graph metadata or error | `POST` | [`/api/user/echolist-preference`](#post-apiuserecholist-preference) | Yes | Update echolist filter preferences for authenticated user. | | `POST` | [`/api/user/activity`](#post-apiuseractivity) | Yes | Update user's current activity status. | | `GET` | [`/api/user/shares`](#get-apiusershares) | Yes | List all message shares created by the authenticated user. | +| `GET` | [`/api/pgp/key/{userId}`](#get-apipgpkeyuserid) | No | List public PGP keys for a user and return the preferred key first. | +| `GET` | [`/api/user/pgp/keys`](#get-apiuserpgpkeys) | Yes | List the authenticated user's saved PGP keys. | +| `POST` | [`/api/user/pgp/keys`](#post-apiuserpgpkeys) | Yes | Upload a public PGP key for the authenticated user. | +| `POST` | [`/api/user/pgp/keys/managed`](#post-apiuserpgpkeysmanaged) | Yes | Store a browser-generated managed PGP keypair for the authenticated user. | +| `POST` | [`/api/user/pgp/keys/{fingerprint}/primary`](#post-apiuserpgpkeysfingerprintprimary) | Yes | Set the preferred public PGP key for the authenticated user. | +| `DELETE` | [`/api/user/pgp/keys/{fingerprint}`](#delete-apiuserpgpkeysfingerprint) | Yes | Delete one of the authenticated user's PGP keys. | +| `GET` | [`/api/user/pgp/private-key/{fingerprint}`](#get-apiuserpgpprivate-keyfingerprint) | Yes | Fetch the encrypted private key blob for a managed PGP key. | | `GET` | [`/api/user/settings`](#get-apiusersettings) | Yes | Retrieve authenticated user's settings and preferences. | | `POST` | [`/api/user/settings`](#post-apiusersettings) | Yes | Update authenticated user's settings and preferences. | | `POST` | [`/api/user/reset-onboarding`](#post-apiuserreset-onboarding) | Yes | Reset echomail onboarding flag for user. | @@ -6784,7 +7626,15 @@ Paginated transaction list | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Operation success flag | -| `transactions` | array | Array of transaction objects with id, user_id, other_party_id, amount, balance_after, description, transaction_type, created_at | +| `transactions` | array | Array of transaction objects | +| `transactions[].id` | integer | Transaction ID | +| `transactions[].user_id` | integer | User who owns this transaction | +| `transactions[].other_party_id` | integer\|null | Other party user ID (for transfers) | +| `transactions[].amount` | integer | Credit amount (positive = credit, negative = debit) | +| `transactions[].balance_after` | integer | Balance after this transaction | +| `transactions[].description` | string | Human-readable description | +| `transactions[].transaction_type` | string | Transaction type code | +| `transactions[].created_at` | string | ISO 8601 creation timestamp | | `offset` | integer | Current offset used in query | | `limit` | integer | Current limit used in query | @@ -6822,12 +7672,16 @@ Paginated activity log entries | Field | Type | Description | |-------|------|-------------| -| `id` | integer | Activity log entry ID | -| `created_at` | string | Timestamp of activity | -| `category` | string | Activity category name | -| `activity` | string | Activity type label | -| `object_name` | string | Name of the object involved in activity | -| `meta` | object | Additional metadata about the activity | +| `success` | boolean | Operation success flag | +| `activity` | array | Array of activity log entries | +| `activity[].id` | integer | Activity log entry ID | +| `activity[].created_at` | string | ISO 8601 timestamp of activity | +| `activity[].category` | string | Activity category name | +| `activity[].activity` | string | Activity type label | +| `activity[].object_name` | string\|null | Name of the object involved in the activity | +| `activity[].meta` | object\|null | Additional metadata (structure varies by activity type) | +| `offset` | integer | Current offset used in query | +| `limit` | integer | Current limit used in query | **Error Responses** @@ -6868,7 +7722,12 @@ List of active sessions | Field | Type | Description | |-------|------|-------------| -| `sessions` | array | Array of session objects with id, ip_address, created_at, expires_at, is_current | +| `sessions` | array | Array of active session objects | +| `sessions[].id` | string | Session token ID | +| `sessions[].ip_address` | string\|null | IP address the session was created from | +| `sessions[].created_at` | string | Session creation timestamp (ISO 8601) | +| `sessions[].expires_at` | string | Session expiry timestamp (ISO 8601) | +| `sessions[].is_current` | integer | 1 if this is the currently active session, 0 otherwise | --- @@ -7022,7 +7881,19 @@ User's message shares | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Whether the shares were successfully retrieved | -| `shares` | array | Array of share objects with keys, slugs, and metadata | +| `shares` | array | Array of message share objects | +| `shares[].id` | integer | Share record ID | +| `shares[].message_id` | integer | ID of the shared message | +| `shares[].message_type` | string | Message type ('echomail' or 'netmail') | +| `shares[].message_subject` | string\|null | Subject of the shared message | +| `shares[].area_tag` | string | Echo area tag (or 'netmail' for netmail shares) | +| `shares[].share_key` | string | Share token string | +| `shares[].share_url` | string | Full URL of the share link | +| `shares[].created_at` | string | Share creation timestamp (ISO 8601) | +| `shares[].expires_at` | string\|null | Share expiry timestamp; null for no expiry | +| `shares[].is_public` | boolean | Whether share is publicly accessible | +| `shares[].access_count` | integer | Number of times the share has been accessed | +| `shares[].last_accessed_at` | string\|null | Last access timestamp (ISO 8601) | **Error Responses** @@ -7032,6 +7903,305 @@ User's message shares --- +#### `GET /api/pgp/key/{userId}` + +Returns the public PGP keys associated with the specified user account. The first key in the response is the preferred key if one is set. + +**Response** _(JSON)_ + +Public PGP key listing for one user. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `preferred_key` | object\|null | Preferred public key for the user, or `null` when no keys are saved | +| `preferred_key.fingerprint` | string | 40-character uppercase PGP fingerprint | +| `preferred_key.armored_public_key` | string | ASCII-armored public key block | +| `preferred_key.source` | string | `uploaded` or `managed` | +| `preferred_key.label` | string\|null | Optional user-defined label | +| `preferred_key.user_id_string` | string\|null | Parsed OpenPGP user ID string | +| `preferred_key.email` | string\|null | Parsed email address from the key, if present | +| `preferred_key.key_algorithm` | string\|null | Parsed public key algorithm | +| `preferred_key.key_created_at` | string\|null | Key creation timestamp in UTC when available | +| `preferred_key.is_primary` | boolean | Whether this key is marked preferred | +| `preferred_key.created_at` | string | Server-side record creation timestamp | +| `keys` | array | All public keys for the user in preferred-first order | +| `keys[].fingerprint` | string | 40-character uppercase PGP fingerprint | +| `keys[].armored_public_key` | string | ASCII-armored public key block | +| `keys[].source` | string | `uploaded` or `managed` | +| `keys[].label` | string\|null | Optional user-defined label | +| `keys[].user_id_string` | string\|null | Parsed OpenPGP user ID string | +| `keys[].email` | string\|null | Parsed email address from the key, if present | +| `keys[].key_algorithm` | string\|null | Parsed public key algorithm | +| `keys[].key_created_at` | string\|null | Key creation timestamp in UTC when available | +| `keys[].is_primary` | boolean | Whether this key is marked preferred | +| `keys[].created_at` | string | Server-side record creation timestamp | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 500 | Failed to load PGP keys | + +--- + +#### `GET /api/pgp/lookup` + +**Requires authentication** + +Performs destination-aware public-key lookup for the compose UI. Local destinations query this BBS's public-key store. Remote FTN destinations first check the authenticated user's saved correspondent keys, then resolve the node's BinkP host from the nodelist, prefer `_hkps._tcp` SRV records when available, and otherwise fall back to `https:///pks/lookup`. + +**Query Parameters** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `search` | string | Yes | Key fingerprint or search text | +| `address` | string | No | Destination FTN address; blank means local delivery | +| `op` | string | No | `index` (default) for a candidate list or `get` for one armored key | +| `mode` | string | No | `compose` (default) for destination-aware compose lookup, or `verify` for message-signature verification that checks saved correspondent keys and never performs remote HKPS lookup | + +**Response** _(JSON, `op=index`)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `is_local_address` | boolean | Whether the destination was treated as local | +| `keys` | array | Matching public keys | +| `keys[].fingerprint` | string | 40-character uppercase PGP fingerprint | +| `keys[].user_id_string` | string\|null | Parsed OpenPGP user ID string or HKP `uid` line | +| `keys[].username` | string\|null | Local BBS username when the match came from the local store | +| `keys[].key_algorithm` | string\|null | Public key algorithm when known | +| `keys[].key_created_at` | string\|null | Key creation timestamp when known | +| `keys[].lookup_source` | string | `local`, `saved_contact`, `remote_srv`, or `remote_host` | +| `keys[].address_book_entry_id` | integer\|null | Linked address-book entry ID when the match came from a saved correspondent key | +| `keys[].address_book_name` | string\|null | Linked address-book contact name when the match came from a saved correspondent key | +| `keys[].address_book_node_address` | string\|null | Linked address-book FTN address when the match came from a saved correspondent key | + +**Response** _(JSON, `op=get`)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `is_local_address` | boolean | Whether the destination was treated as local | +| `key` | object\|null | Resolved public key, or `null` when no match was found | +| `key.fingerprint` | string | 40-character uppercase PGP fingerprint | +| `key.armored_public_key` | string | ASCII-armored public key block | +| `key.user_id_string` | string\|null | Parsed OpenPGP user ID string | +| `key.email` | string\|null | Parsed email address from the key, if available | +| `key.key_algorithm` | string\|null | Parsed public key algorithm | +| `key.key_created_at` | string\|null | Key creation timestamp when known | +| `key.lookup_source` | string | `local`, `saved_contact`, `remote_srv`, or `remote_host` | +| `key.address_book_entry_id` | integer\|null | Linked address-book entry ID when the match came from a saved correspondent key | +| `key.address_book_name` | string\|null | Linked address-book contact name when the match came from a saved correspondent key | +| `key.address_book_node_address` | string\|null | Linked address-book FTN address when the match came from a saved correspondent key | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 500 | Failed to load PGP keys | + +--- + +#### `GET /api/user/pgp/keys` + +**Requires authentication** + +Lists the authenticated user's saved PGP keys, including whether each key has an encrypted managed private key blob stored on the server. + +**Response** _(JSON)_ + +Authenticated user's PGP key inventory. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `keys` | array | Saved PGP keys in preferred-first order | +| `keys[].id` | integer | Database ID for the saved key row | +| `keys[].fingerprint` | string | 40-character uppercase PGP fingerprint | +| `keys[].source` | string | `uploaded` or `managed` | +| `keys[].label` | string\|null | Optional user-defined label | +| `keys[].user_id_string` | string\|null | Parsed OpenPGP user ID string | +| `keys[].email` | string\|null | Parsed email address from the key, if present | +| `keys[].key_algorithm` | string\|null | Parsed public key algorithm | +| `keys[].key_created_at` | string\|null | Key creation timestamp in UTC when available | +| `keys[].is_primary` | boolean | Whether this key is marked preferred | +| `keys[].created_at` | string | Server-side record creation timestamp | +| `keys[].updated_at` | string | Last modification timestamp | +| `keys[].has_private_key` | boolean | Whether an encrypted managed private key blob exists for this key | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 500 | Failed to load PGP keys | + +--- + +#### `POST /api/user/pgp/keys` + +**Requires authentication** + +Uploads an ASCII-armored public key, parses its metadata, and stores it in the authenticated user's key inventory. + +**Request Body** _(JSON)_ + +Public key upload payload. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `armored_public_key` | string | Yes | ASCII-armored public key block | +| `label` | string | No | Optional label shown in settings and key listings | + +**Response** _(JSON)_ + +Stored public key record. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `message_code` | string | Translation key for the success message | +| `key` | object | Stored key metadata | +| `key.id` | integer | Database ID for the key row | +| `key.fingerprint` | string | 40-character uppercase PGP fingerprint | +| `key.source` | string | `uploaded` | +| `key.label` | string\|null | Optional label shown in settings and key listings | +| `key.user_id_string` | string\|null | Parsed OpenPGP user ID string | +| `key.email` | string\|null | Parsed email address from the key, if present | +| `key.key_algorithm` | string\|null | Parsed public key algorithm | +| `key.key_created_at` | string\|null | Key creation timestamp in UTC when available | +| `key.is_primary` | boolean | Whether this key became the preferred key | +| `key.created_at` | string | Server-side record creation timestamp | +| `key.updated_at` | string | Last modification timestamp | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Public key missing or invalid | +| 500 | Failed to save PGP key | + +--- + +#### `POST /api/user/pgp/keys/managed` + +**Requires authentication** + +Stores a browser-generated managed PGP keypair. The server stores the ASCII-armored public key and the encrypted private key blob; it does not expose the private key blob except through the authenticated private-key endpoint. + +**Request Body** _(JSON)_ + +Managed keypair storage payload. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `armored_public_key` | string | Yes | ASCII-armored public key block | +| `encrypted_private_key` | string | Yes | ASCII-armored encrypted private key block | +| `label` | string | No | Optional label shown in settings and key listings | + +**Response** _(JSON)_ + +Stored managed public key record. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `message_code` | string | Translation key for the success message | +| `key` | object | Stored public key metadata | +| `key.id` | integer | Database ID for the key row | +| `key.fingerprint` | string | 40-character uppercase PGP fingerprint | +| `key.source` | string | `managed` | +| `key.label` | string\|null | Optional label shown in settings and key listings | +| `key.user_id_string` | string\|null | Parsed OpenPGP user ID string | +| `key.email` | string\|null | Parsed email address from the key, if present | +| `key.key_algorithm` | string\|null | Parsed public key algorithm | +| `key.key_created_at` | string\|null | Key creation timestamp in UTC when available | +| `key.is_primary` | boolean | Whether this key became the preferred key | +| `key.created_at` | string | Server-side record creation timestamp | +| `key.updated_at` | string | Last modification timestamp | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Public/private keypair missing or invalid | +| 500 | Failed to save PGP key | + +--- + +#### `POST /api/user/pgp/keys/{fingerprint}/primary` + +**Requires authentication** + +Marks one saved PGP key as the preferred public key for the authenticated user. + +**Response** _(JSON)_ + +Preference update confirmation. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `message_code` | string | Translation key for the success message | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 404 | PGP key not found for this user | +| 500 | Failed to save PGP key | + +--- + +#### `DELETE /api/user/pgp/keys/{fingerprint}` + +**Requires authentication** + +Deletes one saved PGP key from the authenticated user's inventory. If the deleted key was preferred, the oldest remaining key is promoted automatically. + +**Response** _(JSON)_ + +Deletion confirmation. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `message_code` | string | Translation key for the success message | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 404 | PGP key not found for this user | +| 500 | Failed to delete PGP key | + +--- + +#### `GET /api/user/pgp/private-key/{fingerprint}` + +**Requires authentication** + +Fetches the encrypted private key blob for one managed PGP key owned by the authenticated user. + +**Response** _(JSON)_ + +Encrypted private key record. + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Operation success flag | +| `fingerprint` | string | 40-character uppercase PGP fingerprint | +| `encrypted_private_key` | string | ASCII-armored encrypted private key block | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 404 | Managed private key not found | +| 500 | Failed to load PGP keys | + +--- + #### `GET /api/user/settings` **Requires authentication** @@ -7045,7 +8215,30 @@ User settings object with locale, shell, notification preferences, and license s | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Operation success flag | -| `settings` | object | Settings object containing locale, shell, chat_notification_sound, echomail_notification_sound, netmail_notification_sound, file_notification_sound, compose_advanced_open, compose_hard_wrap, media_render_mode, license_valid | +| `settings` | object | User settings | +| `settings.locale` | string | UI locale code (e.g., 'en', 'fr') | +| `settings.timezone` | string | User's timezone (e.g., 'America/Los_Angeles') | +| `settings.theme` | string | UI theme (e.g., 'light', 'dark', 'amber') | +| `settings.messages_per_page` | integer | Number of messages shown per page | +| `settings.threaded_view` | boolean | Whether echomail is shown in threaded mode | +| `settings.netmail_threaded_view` | boolean | Whether netmail is shown in threaded mode | +| `settings.default_sort` | string | Default sort order (date_desc, date_asc, subject, author) | +| `settings.font_family` | string | UI font family | +| `settings.font_size` | integer | UI font size in pixels | +| `settings.date_format` | string | Date format locale code (e.g., 'en-US') | +| `settings.quote_coloring` | boolean | Whether quoted text is colorized | +| `settings.default_echo_list` | string | Default echo list view (reader, list) | +| `settings.signature_text` | string\|null | User's message signature | +| `settings.default_tagline` | string\|null | Default message tagline | +| `settings.shell` | string | UI shell preference ('web' or 'bbs-menu') | +| `settings.chat_notification_sound` | string | Chat notification sound (disabled, notify1–5) | +| `settings.echomail_notification_sound` | string | Echomail notification sound (disabled, notify1–5) | +| `settings.netmail_notification_sound` | string | Netmail notification sound (disabled, notify1–5) | +| `settings.file_notification_sound` | string | File notification sound (disabled, notify1–5) | +| `settings.compose_advanced_open` | boolean | Whether advanced compose panel is open by default | +| `settings.compose_hard_wrap` | integer | Hard-wrap column for message composition (0 = disabled) | +| `settings.media_render_mode` | string | Media rendering mode ('click', 'auto') | +| `settings.license_valid` | boolean | Whether the system has a valid license | **Error Responses** @@ -7384,7 +8577,10 @@ User terminal settings | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always true | -| `settings` | object | Terminal settings object containing terminal_charset and terminal_ansi_color | +| `settings` | object | Terminal configuration settings | +| `settings.terminal_charset` | string\|null | Active character set: `utf8`, `cp437`, or `ascii`; null if not set | +| `settings.terminal_ansi_color` | string\|null | ANSI color mode: `yes` or `no`; null if not set | +| `settings.term_shell_mode` | string\|null | Terminal shell mode (e.g. `auto` or a configured shell name); null if not set | --- @@ -7433,7 +8629,15 @@ User mail navigation state | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always true | -| `settings` | object | State object with terminal_netmail_page, terminal_netmail_selected_message_id, terminal_netmail_folder, terminal_netmail_sort, terminal_echomail_areas_page, terminal_echomail_positions, terminal_echomail_sort, and terminal_chat_target | +| `settings` | object | Saved terminal navigation state | +| `settings.terminal_netmail_page` | integer\|null | Last viewed netmail page number, or null if not set | +| `settings.terminal_netmail_selected_message_id` | integer\|null | ID of the last selected netmail message, or null if not set | +| `settings.terminal_netmail_folder` | string\|null | Last viewed netmail folder (`inbox` or `sent`), or null if not set | +| `settings.terminal_netmail_sort` | string\|null | Last used netmail sort order (`date_desc`, `date_asc`, `subject`, `author`), or null if not set | +| `settings.terminal_echomail_areas_page` | integer\|null | Last viewed echomail areas page number, or null if not set | +| `settings.terminal_echomail_positions` | object\|string\|null | Per-area read position map (JSON object or string), or null if not set | +| `settings.terminal_echomail_sort` | string\|null | Last used echomail sort order (`date_desc`, `date_asc`, `subject`, `author`), or null if not set | +| `settings.terminal_chat_target` | object\|string\|null | Last selected terminal chat target (JSON object or string), or null if not set | --- @@ -7576,8 +8780,24 @@ Paginated user list | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True on success | -| `users` | array | Array of user objects | -| `pagination` | object | Pagination metadata (page, limit, total, pages) | +| `users` | array | Array of user account objects | +| `users[].id` | integer | User ID | +| `users[].username` | string | Username | +| `users[].email` | string\|null | Email address | +| `users[].real_name` | string | Real name | +| `users[].fidonet_address` | string\|null | User's FidoNet address | +| `users[].created_at` | string | Account creation timestamp (ISO 8601) | +| `users[].last_login` | string\|null | Last login timestamp (ISO 8601) | +| `users[].last_reminded` | string\|null | Last reminder timestamp (ISO 8601) | +| `users[].is_active` | boolean | Whether account is active | +| `users[].is_admin` | boolean | Whether user has admin privileges | +| `users[].is_system` | boolean | Whether this is a system account | +| `users[].days_since_reminder` | integer\|null | Days since last reminder was sent | +| `pagination` | object | Pagination metadata | +| `pagination.page` | integer | Current page number | +| `pagination.limit` | integer | Results per page | +| `pagination.total` | integer | Total matching users | +| `pagination.pages` | integer | Total number of pages | **Error Responses** @@ -7608,7 +8828,18 @@ User details for editing | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True on success | -| `user` | object | User object with id, username, real_name, email, credit_balance, is_active, is_admin, is_system, echomail_moderation_forced, created_at, last_login | +| `user` | object | User account details | +| `user.id` | integer | User ID | +| `user.username` | string | Username | +| `user.real_name` | string | Real name | +| `user.email` | string\|null | Email address | +| `user.credit_balance` | integer | Current credit balance | +| `user.is_active` | boolean | Whether account is active | +| `user.is_admin` | boolean | Whether user has admin privileges | +| `user.is_system` | boolean | Whether this is a system account | +| `user.echomail_moderation_forced` | boolean | Whether echomail moderation is forced for this user | +| `user.created_at` | string | Account creation timestamp (ISO 8601) | +| `user.last_login` | string\|null | Last login timestamp (ISO 8601) | **Error Responses** @@ -7733,7 +8964,8 @@ Toggle result with localized message |-------|------|-------------| | `success` | boolean | Whether toggle succeeded | | `message_code` | string | Localization key for success message | -| `message_params` | object | Parameters for message (action: 'enable' or 'disable') | +| `message_params` | object | Parameters for localized message | +| `message_params.action` | string | Action taken: 'enable' or 'disable' | **Error Responses** @@ -7797,8 +9029,14 @@ Cleanup operation results |-------|------|-------------| | `success` | boolean | Whether cleanup completed | | `result` | object | Cleanup statistics | +| `result.approved_removed` | integer | Number of approved registration records removed | +| `result.old_rejected_removed` | integer | Number of old rejected registration records removed | +| `result.total_cleaned` | integer | Total records removed | | `message_code` | string | Localization key for success message | -| `message_params` | object | Cleanup counts (approved, rejected, total) | +| `message_params` | object | Parameters for localized message | +| `message_params.approved` | integer | Count of approved records removed | +| `message_params.rejected` | integer | Count of rejected records removed | +| `message_params.total` | integer | Total records removed | **Error Responses** @@ -7856,6 +9094,11 @@ List of users needing reminders |-------|------|-------------| | `success` | boolean | Whether query succeeded | | `users` | array | Array of user objects needing reminders | +| `users[].id` | integer | User ID | +| `users[].username` | string | Username | +| `users[].real_name` | string | Real name | +| `users[].email` | string\|null | Email address | +| `users[].created_at` | string | Account creation timestamp (ISO 8601) | **Error Responses** @@ -7937,6 +9180,21 @@ Returns the effective terminal main menu key map for the current system. Used by |-------|------|-------------| | `success` | boolean | Always `true` | | `term_menu_keys` | object | Map of action ID to single-character key string | +| `term_menu_keys.netmail` | string | Key for Netmail | +| `term_menu_keys.echomail` | string | Key for Echomail | +| `term_menu_keys.shoutbox` | string | Key for Shoutbox | +| `term_menu_keys.bulletins` | string | Key for Bulletins | +| `term_menu_keys.polls` | string | Key for Polls | +| `term_menu_keys.doors` | string | Key for Doors | +| `term_menu_keys.files` | string | Key for Files | +| `term_menu_keys.settings` | string | Key for Settings | +| `term_menu_keys.interests` | string | Key for Interests | +| `term_menu_keys.whosonline` | string | Key for Who's Online | +| `term_menu_keys.qwk` | string | Key for QWK offline mail | +| `term_menu_keys.bbslist` | string | Key for BBS List | +| `term_menu_keys.nodelist` | string | Key for Nodelist | +| `term_menu_keys.localchat` | string | Key for Local Chat | +| `term_menu_keys.quit` | string | Key to quit (always present) | **Error Responses** diff --git a/docs/CLI.md b/docs/CLI.md index 1b7efe891..041970ef4 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -5,6 +5,7 @@ BinktermPHP includes a full suite of CLI tools for managing your system from the ## Table of Contents - [Message Posting Tool](#message-posting-tool) +- [PGP Lookup Tool](#pgp-lookup-tool) - [Weather Report Generator](#weather-report-generator) - [New Files Report](#new-files-report) - [Echomail Traffic Report](#echomail-traffic-report) @@ -60,6 +61,25 @@ php scripts/post_message.php --list-users php scripts/post_message.php --list-areas ``` +## PGP Lookup Tool +Perform the same destination-aware PGP lookup used by netmail compose. The script reports whether the lookup is local or remote, and for remote lookups it prints the exact `/pks/lookup` URL queried. + +```bash +# Local lookup (blank destination address means local delivery) +php scripts/pgp.lookup.php sysop + +# Remote candidate listing for a specific FTN destination +php scripts/pgp.lookup.php alice@example.com --address=2:280/555 + +# Fetch one armored key instead of listing candidates +php scripts/pgp.lookup.php 0123456789ABCDEF0123456789ABCDEF01234567 --address=2:280/555 --get +``` + +Options: +- `--address=FTN` - Destination FTN address. Blank means local delivery. +- `--get` - Fetch one armored public key instead of listing matches. +- `--help` - Show usage. + ## Weather Report Generator Generate detailed weather forecasts for posting to echomail areas: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5c9c2fe4f..9c1d1ec9f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -67,6 +67,7 @@ DB_PORT=5432 DB_NAME=binktest DB_USER=binktest DB_PASS=yourpassword +DB_DRIVER=pgsql DB_SSL=false # Optional SSL (uncomment if your PostgreSQL requires it) diff --git a/docs/ConfigurationSystem.md b/docs/ConfigurationSystem.md index fd855c76a..4878e28be 100644 --- a/docs/ConfigurationSystem.md +++ b/docs/ConfigurationSystem.md @@ -97,7 +97,7 @@ Written via: Admin UI → BinkP Config (via AdminDaemonClient `save_binkp_config ### `config/bbs.json` → `BbsConfig` (Static) -`src/BbsConfig.php` manages BBS-level feature flags, credits settings, AI assistant configuration, echomail moderation, QWK settings, and other sysop-configurable options. +`src/BbsConfig.php` manages BBS-level feature flags, credits settings, AI assistant configuration, echomail moderation, QWK settings, PGP availability, managed-key hosting policy, and other sysop-configurable options. On load it merges `config/bbs.json` over `config/bbs.json.example` using `array_replace_recursive`. The `features` sub-key is handled separately: each known feature is explicitly cast to `bool`, and features absent from `bbs.json` inherit the example default. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 63ceaceee..a91e0d62c 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -416,6 +416,44 @@ Use `AdminDaemonClient::log($level, $message, $context)` for application-level l **Maintaining `docs/API.md`**: The repository's `docs/API.md` is the canonical REST API reference and must be kept up to date as routes are added, modified, or removed. Update it by hand for incremental changes. Do not use the generator to overwrite `docs/API.md` — the script is intended for local exploration and bulk regeneration by maintainers, not as a substitute for keeping the committed doc current. +### Response table format + +Every `**Response** _(JSON)_` block must include a pipe-delimited field table. Incomplete tables are the most common documentation debt — follow these rules for every route change: + +**Rule 1 — Expand every `object` and `array of objects` field.** +A row typed `object` or `array` (or `array of objects`) with no sub-field rows below it is incomplete. Every such field must have its child fields listed in the same table: +- Objects use dot-notation: `parent.child` +- Arrays use bracket-notation: `items[].child` + +**Rule 2 — Atomic array elements do not need sub-rows.** +Use a specific type like `array of strings` or `array of integers` in the Type column. These do not need further expansion. + +**Rule 3 — Dynamic structures must explain themselves in prose.** +If a field's keys vary at runtime (e.g. a namespace-keyed i18n map, a command-specific payload), sub-rows cannot be defined. In that case the Type column must say `object` and the Description cell must explain the key and value shape in plain English. Do not leave the description as just "Object". + +**Rule 4 — Cross-section references are explicit, not implicit.** +If the object shape is documented in a dedicated sub-section (e.g. a "Message object" table), the parent array row's Description must include a parenthetical like `(see **Message object** below)`. Do not leave the type as bare `array` with a generic description and expect the reader to find the adjacent section. + +#### Complete response table example + +```markdown +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the operation succeeded | +| `user` | object | Updated user record | +| `user.id` | integer | User ID | +| `user.username` | string | Username | +| `user.created_at` | string | UTC timestamp when account was created | +| `tags` | array of strings | Slugs of tags applied to the user | +| `sessions` | array of objects | Currently active sessions | +| `sessions[].id` | string | Session token | +| `sessions[].ip_address` | string | Client IP address | +| `sessions[].created_at` | string | Session start time (UTC) | +``` + + ### Output formats | Format | Flag | Use case | diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 6fd44a6b6..a1b28f64e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -36,6 +36,8 @@ BinktermPHP is developed and tested on Debian-based Linux distributions, includi - **Feature-specific dependencies** - Some optional features, such as DOS Doors, may require additional installation steps. See each feature's documentation for its specific requirements. - **Hardware Recommendation** - If you are running all services, we recommend at least 2 GB of RAM and 2 CPU cores - **Sizing Note** - Running fewer services generally requires less RAM +- **Hosting Recommendation** - Use a VPS, dedicated server, Raspberry Pi, or other environment where you control the OS, web server, firewall, and background services +- **Shared Hosting** - Not recommended. BinktermPHP requires PostgreSQL and uses multiple service ports and long-running daemons that many shared hosting environments do not permit - **Operating System** - Designed with Linux in mind, should also run on MacOS, Windows (with some caveats) - **Operating User** - BinktermPHP should run under its own dedicated user account, not as root or your personal login. See [Create BBS User Account](#create-bbs-user-account) below. diff --git a/docs/PGP.md b/docs/PGP.md new file mode 100644 index 000000000..1f078b664 --- /dev/null +++ b/docs/PGP.md @@ -0,0 +1,206 @@ +# PGP Key Management + +This guide covers BinktermPHP's PGP support from a sysop's point of view: how to enable it, what it exposes to users, and how the managed-private-key option changes behavior. + +## What The Feature Does + +PGP support lets each user maintain more than one public key and choose a preferred key for their account. When enabled, users get a **PGP** tab in their settings page where they can: + +- upload one or more public keys +- pick which key is primary +- generate a BBS-managed private key pair, if that policy is allowed +- save correspondent public keys privately through the address book for encrypted replies + +The platform also exposes a public keyserver view so other systems can look up published keys. When managed private keys are enabled, the browser can also use them for netmail encryption and echomail signing. + +Like any OpenPGP deployment, users can also do PGP work entirely outside the BBS +by copying and pasting ASCII-armored text into their own local tools. The BBS-side +managed-key option is there for users and sysops who want browser-based encryption, +decryption, and signing directly inside BinktermPHP instead of using an external +keyring or desktop mail workflow, and who accept the policy and liability tradeoffs +that come with allowing the BBS to host encrypted private-key blobs. + +## Default State + +PGP is disabled by default. Fresh installations start with both of these BBS settings turned off: + +- `Enable PGP` +- `Allow BBS-managed private keys` + +That means a sysop must explicitly turn the feature on before users see the PGP settings tab or the public keyserver routes. + +## Enabling PGP + +Open **Admin -> BBS Settings** and enable: + +- `Enable PGP` to expose user key management and the public keyserver +- `Allow BBS-managed private keys` only if you want the system to host encrypted private keys for users + +The second setting depends on the first one. If PGP is disabled, managed-key generation is also unavailable. + +## User-Facing Behavior + +When `Enable PGP` is on: + +- users see a **PGP** tab in their settings page +- users can upload armored public keys +- users can select a preferred key from a dropdown/list +- users can view, copy, or download their saved public key material +- the public keyserver becomes available + +The keyserver publishes the user's preferred key and key listings for lookup. + +In this mode, users can still use the system as a public-key directory while doing +their actual encryption and decryption locally with their own OpenPGP software and +ASCII-armored messages. + +The public keyserver search is network-aware. In addition to plain username, +real-name, email, and fingerprint searches, the `/keyserver` web UI also +understands qualified forms such as: + +- `awehttam@claudes.lovelybits.org` +- `awehttam@227:1/200` +- `Firstname Lastname@1:153/150` + +When the suffix is a hostname, `/keyserver` performs a remote HKPS lookup against +that host. When the suffix is an FTN address, `/keyserver` resolves the node from +the nodelist and performs the same remote HKPS lookup flow used by encrypted +netmail compose. + +When `Allow BBS-managed private keys` is also on: + +- the PGP settings page shows a managed-key generator +- generated private keys are handled by the browser and stored by the BBS in encrypted form +- users can retrieve their stored private key material later from their account key list +- users can sign outgoing echomail with their stored private key from the compose screen +- readers can decrypt encrypted netmail or verify signed echomail in the message viewer + +This is the optional in-BBS workflow. Instead of requiring users to leave the site +and use an external OpenPGP program, the browser can perform encryption, decryption, +and signing against the user's managed key material directly in the BinktermPHP UI. + +When decrypting, the reader inspects the encrypted message's recipient key IDs and +prefers the matching managed private key. If no direct match is found, it falls +back to the other stored managed private keys for the account. + +Signed-message verification in the reader is intentionally private-keyring driven +for remote correspondents. The browser first checks the authenticated user's saved +correspondent/address-book keys and does not perform remote HKPS lookups during +signature verification. If the sender is local to this BBS, verification can also +fall back to the local published public-key store. + +If managed keys are disabled, the PGP tab still works for public-key upload and preferred-key selection, but the generator section is replaced with a notice. + +If `Allow BBS-managed private keys` is off, the server does not provide the stored private key material needed for browser-side signing or decryption. In that mode, users can still publish public keys and choose a primary key, and they can still encrypt outgoing netmail to a published recipient public key from the compose screen, but browser-side signing and reader-side decryption stay unavailable. + +## Compose Lookup + +The compose form exposes its PGP controls inside **Advanced Options**. + +- on netmail compose, users can enable encryption there +- on echomail compose, users can enable signing there +- signing still prompts for the managed-key passphrase when needed + +Netmail encryption uses the compose form's recipient lookup. The browser searches for public keys using the text in the recipient fields and shows an explicit selector before the message is encrypted. + +The lookup can match: + +- the key fingerprint +- the published PGP user ID +- the key label, if one was set +- the BBS user's username or real name +- saved address-book entries and local user matches surfaced by the address-book search API + +This matters because a user's published PGP identity and their BBS account name are not always the same thing. The selector is there so the user can choose the exact public key before sending. + +When the destination address is blank or points at one of this system's own FTN addresses, compose searches the local public-key store only. + +When the destination is a remote FTN address, compose switches to remote lookup: + +- it first checks the current user's saved correspondent keys, including any key linked to an address-book contact +- it resolves the destination node in the nodelist +- it extracts the node's BinkP hostname from the nodelist flags +- it checks for `_hkps._tcp.` SRV records and uses that target when present +- if no HKPS SRV record exists, it falls back to `https:///pks/lookup` + +Remote HKP requests use a 3-second timeout so compose does not stall for long on unreachable systems. + +Saved correspondent keys are private to the owning user account. They are not published on `/pks/lookup` and do not change the user's preferred public key on the keyserver. + +Netmail encryption does not require the sender to have a managed private key. It only needs the selected recipient public key. Echomail signing still requires the sender's managed private key and passphrase because the browser must unlock the stored private key material to create the signature. + +## Public Endpoints + +When PGP is enabled, the following routes are active: + +- `/keyserver` +- `/pks/lookup` +- `/pks/lookup/v1/get/{search}` +- `/pks/add` +- `/pks/download/{fingerprint}` +- `/.well-known/openpgpkey/{domain}/hkps` + +These are the public-facing keyserver routes used for discovery and retrieval. + +The `.well-known` HKPS discovery file advertises the local keyserver using the +same site host configured for BinktermPHP. Clients that follow it can then use +the v1 HKPS path at `/pks/lookup/v1/get/{search}` for exact key retrieval. + +For that discovery path to work from other systems, the relevant hostname must +actually resolve to this BinktermPHP instance. In practice that usually means +`openpgpkey.` needs DNS and web routing to this app, unless the +site host itself already uses that name. + +Example using `mybbs.example.com` as the public Internet hostname of the BBS: + +```dns +; Main HTTPS host used by the BBS itself +mybbs.example.com. IN A 203.0.113.10 + +; Optional HKPS SRV record used by remote FTN lookup +_hkps._tcp.mybbs.example.com. IN SRV 0 1 443 mybbs.example.com. + +; Optional WKD/OpenPGP discovery host if you want external clients +; to use /.well-known/openpgpkey/.../hkps +openpgpkey.example.com. IN CNAME mybbs.example.com. +``` + +If you publish a BinkP hostname for your node in nodelists, it should match the +hostname remote systems are expected to use for PGP key lookup. In this example, +that means the node's advertised BinkP host should also be `mybbs.example.com`. + +The authenticated compose UI also uses `GET /api/pgp/lookup` for destination-aware local-vs-remote key resolution. + +## CLI Helper + +The repository also includes a CLI helper at `scripts/pgp.lookup.php`. + +It uses the same destination-aware local-vs-remote logic as netmail compose: + +- local destinations query the local key store +- remote FTN destinations use saved correspondent keys first, then remote HKPS lookup + +For remote lookups it prints the exact `/pks/lookup` endpoint being queried, which +is useful for testing node resolution and HKPS discovery outside the browser. + +## Operational Notes + +- Use the admin UI to change the BBS settings. The feature flags live in `config/bbs.json`, but the supported path is **Admin -> BBS Settings**. +- If the PGP tab is missing from user settings, confirm that `Enable PGP` is on. +- If users can upload keys but cannot generate managed keys, confirm that `Allow BBS-managed private keys` is on. +- Changing the preferred key affects which key is published and used as the user's primary public key. +- Netmail encryption only requires the recipient public key. Echomail signing and reader-side decryption require managed private keys. If you leave managed keys disabled, the signing and decrypt flows stay unavailable, but users can still use compose-time netmail encryption against published recipient keys. +- If users see address-book results but not local user matches in the compose autocomplete, confirm the address-book search route is returning both saved entries and local-user matches. + +## Administration Checklist + +Before you enable this feature in production, decide whether you want the BBS to host private key material at all. + +Recommended rollout order: + +1. Enable `Enable PGP` +2. Test public-key upload and preferred-key selection +3. Decide whether to enable `Allow BBS-managed private keys` +4. Communicate the policy to users so they know whether they should generate keys locally or use the managed option + +If you only want public-key publishing, leave managed private keys disabled. diff --git a/docs/PacketBBS.md b/docs/PacketBBS.md index ea9b3332e..2023c2d3c 100644 --- a/docs/PacketBBS.md +++ b/docs/PacketBBS.md @@ -1020,6 +1020,8 @@ The page shows: - a sortable table listing all registered nodes by handle, interface type, and location description - a node info modal (handle, location description, coordinates with a copy button, public key with a copy button, and QR code) +For MeshCore bridge nodes, the page prefers the latest live coordinates and last-seen timestamp from `meshcore_node_adverts` when the node's full public key is known. Admin-entered location text remains the fallback description shown in the directory. + Linking to a specific node info modal uses the URL hash `#node-{id}`: ```text @@ -1052,6 +1054,12 @@ During normal operation, the bridge also reports contacts pushed by the radio in The BBS upserts on the contact's full 64-character public key. If a user has already pre-registered a contact by its 12-character prefix (see [User Radio Registration](#user-radio-registration) below), the incoming full-key report claims that row and fills in the complete key. +### MeshCore Advert Storage + +The bridge reports repeater adverts to `POST /api/meshcore/advert`. These live adverts are stored in `meshcore_node_adverts`, keyed by full public key, rather than being merged directly into `cwn_networks`. + +The CWN WebDoor reads MeshCore advert rows through a projected union so the public CWN map and list still show heard MeshCore repeaters alongside manual CWN submissions. Legacy `cwn_networks.source_type = 'meshcore'` rows are treated as migration-only data and are no longer the live write target. + ### Contact Identifiers Each MeshCore contact has two key identifiers: diff --git a/docs/Pipe_Code_Support.md b/docs/Pipe_Code_Support.md index 17ea5d9b0..da77cc092 100644 --- a/docs/Pipe_Code_Support.md +++ b/docs/Pipe_Code_Support.md @@ -47,7 +47,21 @@ Examples: ## Usage -Pipe codes are automatically detected and rendered when displaying messages. No special configuration is needed. +Pipe codes are automatically detected and rendered when displaying messages. + +### Parser Mode + +Pipe-code detection is controlled by the `PIPE_CODE_PARSER_MODE` environment variable in `.env`: + +```env +PIPE_CODE_PARSER_MODE=decimal_relaxed +``` + +Supported values: + +- `strict` - conservative uppercase-only parsing with a trailing boundary check. Avoids false positives such as `|Advertise`, but can leave sequences like `|01A side of beans` unparsed. +- `decimal_relaxed` - default. Greedily accepts two-digit decimal color codes (`|00`-`|99`) even when followed by uppercase text, so `|01A side of beans` parses as `|01` + `A side of beans`. Uppercase hex-with-letter codes and two-letter control codes remain stricter. +- `loose` - legacy permissive behavior for experiments. Re-enables broader matching and can reintroduce false positives in normal prose. ### In Message Bodies @@ -71,7 +85,7 @@ Pipe codes can be mixed with ANSI escape sequences in the same message: The pipe code parser works by: -1. Detecting pipe codes in the format `|XX` where XX is a hex digit +1. Detecting pipe codes according to `PIPE_CODE_PARSER_MODE` 2. Converting them to ANSI escape sequences 3. Processing through the existing ANSI parser @@ -163,8 +177,9 @@ Mystic BBS theme colour codes (`|T0`–`|T9`) are also stripped as they are them ## Notes -- Pipe codes are case-insensitive (`|0F` = `|0f`, `|CL` = `|cl`) +- In `strict` and `decimal_relaxed` modes, letter-based pipe codes are uppercase-sensitive by design. +- `loose` mode restores case-insensitive matching for experiments. - Mystic-style hex colour codes (`|0A`–`|FF`) are parsed as two hex nibbles: upper = background, lower = foreground - Renegade-style decimal colour codes (`|00`–`|23`) use decimal values: 00–15 = foreground, 16–23 = background -- All unrecognised two-letter codes are stripped (rather than passed through as literal text) +- Recognized two-letter control/info codes are stripped; unknown ones are preserved as literal text - The implementation covers Renegade, Mystic, Synchronet, and other FTN-compatible BBS software diff --git a/docs/PostgreSQLDependencies.md b/docs/PostgreSQLDependencies.md index 628bf33e7..e03e87db7 100644 --- a/docs/PostgreSQLDependencies.md +++ b/docs/PostgreSQLDependencies.md @@ -36,22 +36,24 @@ Suggested fields for new entries: ### Hardcoded PostgreSQL DSN and session setup - Why PostgreSQL-specific: - - uses `pgsql:` DSN construction - - uses PostgreSQL session commands such as `SET TIME ZONE` and `SET application_name` + - the current platform abstraction still resolves to a PostgreSQL implementation only + - PostgreSQL session commands such as `SET TIME ZONE` and `SET application_name` remain PostgreSQL-specific behavior - Current locations: - `src/Database.php` + - `src/DatabasePlatform/PostgresPlatform.php` - Likely future strategy: - - introduce a small database platform layer for DSN construction and session initialization + - extend the existing platform layer with additional implementations if compatibility work is ever pursued - Difficulty: - medium ### PostgreSQL-only base schema install path - Why PostgreSQL-specific: - - installer loads `database/postgresql_schema.sql` + - the base schema path is now abstracted, but still resolves only to PostgreSQL - Current locations: - `src/Database.php` - `scripts/install.php` + - `src/DatabasePlatform/PostgresPlatform.php` - Likely future strategy: - select base schema by configured engine - add `database/mysql_schema.sql` only if compatibility work is ever pursued @@ -66,7 +68,7 @@ Suggested fields for new entries: - depends on PostgreSQL pub/sub behavior - uses native PostgreSQL client functions, not generic PDO - Current locations: - - `scripts/ai_bot_daemon.php` + - `src/Realtime/PostgresEventListener.php` - Likely future strategy: - replace with an event transport abstraction - possible backends: Redis pub/sub, polling, queue daemon @@ -78,7 +80,7 @@ Suggested fields for new entries: - Why PostgreSQL-specific: - directly calls PostgreSQL notification functions - Current locations: - - `src/Realtime/BinkStream.php` + - `src/Realtime/PostgresEventPublisher.php` - Likely future strategy: - notifier interface with PostgreSQL implementation now and alternative backend later - Difficulty: @@ -89,6 +91,9 @@ Suggested fields for new entries: - Why PostgreSQL-specific: - uses PL/pgSQL trigger functions and `pg_notify` - Current locations: + - `database/migrations/v1.11.0.54_chat_notify_trigger.php` + - `database/migrations/v1.11.0.55_sse_events_table.php` + - `database/migrations/v1.11.0.57_sse_events_user_targeting.php` - `database/migrations/v1.11.0.58_dashboard_stats_triggers.php` - `database/migrations/v1.11.0.67_targeted_dashboard_stats_triggers.php` - Likely future strategy: @@ -119,6 +124,29 @@ Representative examples: - `src/Chat/ChatMessageService.php` - `src/MessageHandler.php` +### `ON CONFLICT` + +- Why PostgreSQL-specific: + - PostgreSQL upsert syntax and conflict-target behavior are used widely across the codebase + - some queries rely on PostgreSQL-specific forms such as conflict targets, predicates, and returned-row assumptions +- Current locations: + - many files under `src/`, `routes/`, `database/postgresql_schema.sql`, and `database/migrations/` +- Likely future strategy: + - isolate common upsert patterns behind helper methods where practical + - redesign per-engine upsert behavior explicitly rather than assuming direct syntax parity +- Difficulty: + - medium + +Representative examples: + +- `src/AreaFixManager.php` +- `src/BbsDirectory.php` +- `src/BulletinManager.php` +- `src/PacketBbs/PacketBbsSession.php` +- `src/UserMeta.php` +- `routes/api-routes.php` +- `routes/packetbbs-routes.php` + ### `ILIKE` - Why PostgreSQL-specific: @@ -285,6 +313,62 @@ Representative schema examples: - Difficulty: - medium +## PostgreSQL Catalog, Stats, And Introspection + +### PostgreSQL system catalog and statistics views + +- Why PostgreSQL-specific: + - relies on PostgreSQL system views and helper functions such as `pg_stat_*`, `pg_class`, `pg_namespace`, `pg_locks`, `pg_stat_replication`, and `pg_size_pretty(...)` +- Current locations: + - `src/DatabaseStats.php` + - `scripts/binktop.php` + - `scripts/database_maintenance.php` + - some tests and analysis scripts +- Likely future strategy: + - keep operational reporting behind backend-specific services + - do not assume these views exist on non-PostgreSQL backends +- Difficulty: + - high + +### PostgreSQL schema introspection in migrations + +- Why PostgreSQL-specific: + - queries PostgreSQL catalogs and PostgreSQL helper functions such as `pg_constraint` and `pg_get_constraintdef(...)` +- Current locations: + - `database/migrations/v1.11.0.63_mrc_local_presence_user_id.php` +- Likely future strategy: + - keep advanced migration introspection in PHP migrations + - branch explicitly by backend if compatibility work is ever pursued +- Difficulty: + - medium + +## Tooling And External Integration + +### PostgreSQL backup and restore tooling + +- Why PostgreSQL-specific: + - relies on PostgreSQL command-line tools such as `pg_dump` and `pg_restore` +- Current locations: + - `scripts/backup_database.php` + - `scripts/restore_database.php` + - related operational documentation +- Likely future strategy: + - introduce backend-specific backup/restore tooling only if additional engines are ever supported +- Difficulty: + - medium + +### MCP server PostgreSQL-specific SQL + +- Why PostgreSQL-specific: + - the Node MCP server uses PostgreSQL-specific SQL and functions such as `pg_catalog.textsend`, `convert_from(...)`, and `ILIKE` +- Current locations: + - `mcp-server/server.js` +- Likely future strategy: + - treat MCP database access as its own compatibility surface + - isolate query rewrites there separately from the PHP app +- Difficulty: + - medium to high + ## Migration Hotspots ### PostgreSQL-specific migration language and behavior diff --git a/docs/TerminalServer.md b/docs/TerminalServer.md index 9dd80170e..041eb6c03 100644 --- a/docs/TerminalServer.md +++ b/docs/TerminalServer.md @@ -72,6 +72,8 @@ Custom shells can be added by placing plugin definition files in The terminal server uses a shell abstraction layer (`TuiShell` for normal-size terminals, `LineShell` for low-capability sessions) to present UI intents consistently across feature handlers. For architecture details, the widget reference, shell selection rules, style profile, and developer patterns see [Terminal Server Developer Guide](TerminalServerDevGuide.md). +Pipe-code rendering for plain bulletins and other ANSI/pipe text shared with the web renderer is controlled by the `.env` setting `PIPE_CODE_PARSER_MODE`. The default is `decimal_relaxed`, which favors common BBS content where decimal pipe codes are immediately followed by uppercase text. `strict` remains available when avoiding false positives in prose matters more. + ### Screen-Aware Display - Automatically detects terminal dimensions via NAWS (Negotiate About Window Size) diff --git a/docs/TerminalServerDevGuide.md b/docs/TerminalServerDevGuide.md index 5cb46c751..e7e187a75 100644 --- a/docs/TerminalServerDevGuide.md +++ b/docs/TerminalServerDevGuide.md @@ -2,6 +2,14 @@ Architectural and implementation reference for developers working on the BinktermPHP terminal server. For the user-facing feature reference and sysop configuration options see [TerminalServer.md](TerminalServer.md). +## Pipe Code Parser Mode + +Terminal-side bulletin rendering in `telnet/src/BulletinsHandler.php` follows the shared `.env` setting `PIPE_CODE_PARSER_MODE` so sysops can experiment with the same detection strategy on both the web and terminal sides. + +- `strict` matches the conservative uppercase-only behavior. +- `decimal_relaxed` is the default and greedily accepts two-digit decimal color codes such as `|01` before uppercase text. +- `loose` restores the older permissive matcher for debugging and comparison. + --- ## Key Source Files diff --git a/docs/UPGRADING_1.9.8.md b/docs/UPGRADING_1.9.8.md index 9fe53317d..efdd2c686 100644 --- a/docs/UPGRADING_1.9.8.md +++ b/docs/UPGRADING_1.9.8.md @@ -6,6 +6,8 @@ Make sure you have a current backup of your database and files before upgrading. - [Summary of Changes](#summary-of-changes) - [Web Interface](#web-interface) +- [PGP Key Management](#pgp-key-management) +- [Developer / Infrastructure](#developer--infrastructure) - [Upgrade Instructions](#upgrade-instructions) - [From Git](#from-git) - [Using the Installer](#using-the-installer) @@ -16,6 +18,21 @@ Make sure you have a current backup of your database and files before upgrading. - The user-facing echoarea subscription manager at `/subscriptions` now uses a more compact filter layout modeled after `/echolist`, with network filtering and an option to show only interest groups that currently have message traffic. - Subscribing or unsubscribing from an echoarea in `/subscriptions` now updates in place instead of reloading the page, preserving the current scroll position and active search/filter state. +- The subscribed echomail message list now avoids duplicate unread-count work, skips unnecessary joins in its pagination count query, and deduplicates overlapping client-side refreshes, which reduces page-load time on systems with large echomail message bases. +- Pipe-code rendering now defaults to a new `decimal_relaxed` parser mode so decimal color codes such as `|01` still parse when immediately followed by uppercase text. The parser behavior can be overridden with the new `.env` setting `PIPE_CODE_PARSER_MODE`. +- User settings now include a PGP tab where users can upload multiple public keys, choose a preferred key, and browse the public keyserver. +- BBS-managed private key hosting is available behind a separate sysop toggle and is off by default. +- MeshCore repeater adverts are now stored in a dedicated `meshcore_node_adverts` table keyed by full public key. The CWN map/list and the public PacketBBS node directory still show MeshCore nodes after upgrade, but live advert writes no longer go into `cwn_networks`. + +### Developer / Infrastructure + +- Realtime wake-up signaling now has a small transport abstraction around PostgreSQL `LISTEN/NOTIFY`. The current implementation is still PostgreSQL-only, but the direct `pg_*` calls are now concentrated in dedicated realtime classes instead of being spread across `BinkStream`, the AI bot daemon, and the admin daemon. +- Database bootstrap now has a minimal platform abstraction for DSN construction, session initialization, and base schema selection. PostgreSQL remains the only supported backend, but connection and setup behavior is no longer hardcoded in one place. +- `.env` may now include `DB_DRIVER=pgsql`. PostgreSQL is still the only supported value today. This setting exists to make future backend setup work easier to isolate if it is ever pursued. +- `.env` may now include `PIPE_CODE_PARSER_MODE` to control how BBS pipe color codes are recognized by the web renderer and terminal bulletin renderer. +- A new developer reference document, `docs/PostgreSQLDependencies.md`, tracks intentional PostgreSQL-specific dependencies and where they currently live. +- BinkP session logging now closes failed session rows more aggressively and retires orphaned `active` rows whose handler process has already exited, so the admin BinkP session view no longer treats dead pre-handshake sessions as long-running live connections. +- The `user_settings.theme` column now allows up to 300 characters instead of 20 so custom theme stylesheet paths and longer theme identifiers can be stored without truncation. --- @@ -35,6 +52,121 @@ The updated page adds: This change is user-facing only. It does not alter subscriptions, interest membership, or message access rules. +### Echomail List Performance + +The subscribed-message view behind `/echomail` now does less repeated database work per page load on large systems. + +What changed: + +- the list endpoint no longer performs its own duplicate unread-count scan for the subscribed-all-areas view +- the unread badge continues to refresh from the existing stats endpoint +- the pagination total query now avoids joining read-state and saved-message tables unless the selected filter actually needs them +- the browser now coalesces overlapping echomail stats refreshes for the same scope and reuses recent stats for a short window instead of issuing back-to-back duplicate requests +- high-churn refresh paths such as initial load, visibility restore, websocket-triggered updates, and bulk actions now share a centralized refresh flow instead of independently reloading the sidebar, list, and stats endpoints + +On systems with large echomail message bases and users subscribed to many areas, this reduces the amount of full-table work needed to render the default message list without changing the visible behavior of the page. + +### PGP Key Management + +The new PGP settings tab lets users manage multiple public keys on a single account. Users can upload armored public keys, choose a preferred key, and browse the public keyserver from the keyserver link in settings. + +Two BBS-level flags control the feature: + +- `Enable PGP` turns the user-facing PGP tab and public keyserver on or off +- `Allow BBS-managed private keys` controls whether users can generate and store a BBS-managed private key pair + +Both settings default to off. After upgrading, sysops who want the feature must enable it in **Admin -> BBS Settings**. + +If managed private keys are disabled, users can still upload public keys and select a primary key, but the private-key generator is hidden. + +The compose page now also uses the public-key directory for netmail encryption lookups. When users enable `Encrypt this netmail`, the UI searches the keyserver using the recipient text and shows an explicit public-key selector before sending. That lookup can surface: + +- the user's published PGP UID +- the key fingerprint +- the key label +- matching BBS usernames and real names +- saved address-book entries, including local-user matches surfaced by the address-book search API + +If the compose autocomplete only shows saved contacts and not local users, make sure the address-book search route is returning both data sources. The current implementation exposes both through `GET /api/address-book?search=...` and the legacy `/api/address-book/search/{query}` alias. + +### MeshCore Advert Storage Refactor + +MeshCore repeater advert ingest no longer writes directly into `cwn_networks`. + +Instead: + +- live repeater adverts are written to `meshcore_node_adverts` +- `packet_bbs_nodes` gains a nullable `public_key` column so registered MeshCore bridge nodes can be linked to their live advert rows +- the CWN WebDoor now reads manual CWN rows plus live MeshCore advert rows through a projected union + +During `php scripts/setup.php`, the new migration backfills existing legacy `cwn_networks.source_type = 'meshcore'` rows into `meshcore_node_adverts`. Manual CWN submissions stay in `cwn_networks` unchanged. + +If you use MeshCore or PacketBBS bridge nodes, make sure `php scripts/setup.php` completes successfully before letting the bridge send fresh adverts. Until the migration has run, the new advert endpoint will not have its destination table available. + +## Developer / Infrastructure + +### Realtime Signaling Abstraction + +The realtime event path now uses dedicated transport and maintenance classes instead of inlining PostgreSQL signaling details directly into each caller. + +This change does not alter the current supported backend. BinktermPHP still requires PostgreSQL, and BinkStream still uses PostgreSQL notifications today. + +What changed: + +- `src/Realtime/BinkStream.php` now publishes wake-up notifications through a dedicated publisher class +- `scripts/ai_bot_daemon.php` now listens through a dedicated PostgreSQL event listener class +- `src/Admin/AdminDaemonServer.php` now delegates `sse_events` cleanup to a maintenance service + +This keeps the current PostgreSQL behavior while making future transport changes, such as Redis-backed wake-ups, easier to isolate. + +### Database Bootstrap Abstraction + +Database bootstrap now resolves platform-specific setup behavior through dedicated classes under `src/DatabasePlatform/`. + +Current scope: + +- DSN construction +- session initialization +- base schema path selection + +PostgreSQL is still the only supported platform. The new `DB_DRIVER` setting should remain `pgsql`. + +### Pipe Code Parser Mode + +Pipe-code rendering now has a runtime parser-mode setting shared by the web ANSI renderer and the terminal bulletin renderer. + +The new `.env` setting is: + +```env +PIPE_CODE_PARSER_MODE=decimal_relaxed +``` + +Supported modes: + +- `decimal_relaxed` - default. Greedily accepts two-digit decimal color codes such as `|01` even when the following text starts with an uppercase letter. +- `strict` - keeps the more conservative uppercase-boundary checks to reduce false positives in ordinary prose. +- `loose` - restores broader legacy matching for testing and comparison. + +This change is primarily intended to improve compatibility with messages that contain decimal pipe color codes immediately followed by uppercase text, such as `|01A side of beans`, without forcing a code change when sysops want to compare parser behavior. + +### User Theme Length Increase + +The `user_settings.theme` column has been widened from `VARCHAR(20)` to `VARCHAR(300)`. + +This supports longer stored theme values, including custom stylesheet paths that exceed the previous 20-character limit. + +### BinkP Session Log Cleanup + +The BinkP session log now treats abnormal session termination more defensively. + +What changed: + +- the inbound and outbound BinkP session wrappers now close the session log row when a PHP `Throwable` escapes the normal handshake or transfer flow +- the admin `active` BinkP session listing now retires older `active` rows whose recorded handler PID is no longer running +- `scripts/database_maintenance.php` now includes a stale-session cleanup pass before age-based BinkP log retention cleanup + +This keeps the admin BinkP dashboard aligned with real process state when a remote peer connects, drops during handshake, and the handler process exits before the session log row was finalized. + ## Upgrade Instructions ### From Git diff --git a/docs/index.md b/docs/index.md index 7836e0a28..7627b28cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,6 +77,7 @@ Complete reference for sysops and developers. New here? Start with [Getting Star - [Local Chat](LocalChat.md) — Real-time room chat and direct messages: rooms, moderation, Matterbridge bridging, and AI bots - [MRC Chat](MRC_Chat.md) — Multi-Relay Chat protocol integration +- [PGP Key Management](PGP.md) — User public-key upload, preferred-key selection, public keyserver publishing, and managed private keys - [Shoutbox](Shoutbox.md) — Public message wall: posting, moderation, and dashboard card - [Bulletins](Bulletins.md) — Sysop notices shown at login and on demand: scheduling, display modes, and terminal rendering - [Voting Booth](VotingBooth.md) — User polls: creating, voting, results, and terminal access diff --git a/public_html/js/ansisys.js b/public_html/js/ansisys.js index acfd0b531..25bd1c3d5 100644 --- a/public_html/js/ansisys.js +++ b/public_html/js/ansisys.js @@ -1592,6 +1592,40 @@ function hasAnsiCodes(text) { return /\x1b[\[\]PX^_][^\x1b]*|(\x1b.)/.test(text); } +function getPipeCodeParserMode() { + const mode = String(window.siteConfig?.pipeCodeParserMode || 'decimal_relaxed').toLowerCase(); + if (mode === 'decimal_relaxed' || mode === 'loose') { + return mode; + } + return 'decimal_relaxed'; +} + +function getPipeCodeDetectionRegex() { + const mode = getPipeCodeParserMode(); + if (mode === 'decimal_relaxed') { + return /\|(?:[0-9]{2}|[0-9](?![0-9])|[0-9A-F][A-F](?![0-9A-F])|[A-Z]{2}(?![A-Z]))/; + } + if (mode === 'loose') { + return /\|(?:[0-9](?![0-9A-Fa-f])|[0-9A-Fa-f]{2}|[A-Z]{2})/i; + } + return /\|(?:[0-9](?![0-9A-F])|[0-9A-F]{2}(?![0-9A-F])|[A-Z]{2}(?![A-Z]))/; +} + +function getPipeCodeColorRegex() { + const mode = getPipeCodeParserMode(); + if (mode === 'decimal_relaxed') { + return /\|([0-9]{2}|[0-9](?![0-9])|[0-9A-F][A-F](?![0-9A-F]))/g; + } + if (mode === 'loose') { + return /\|([0-9](?![0-9A-Fa-f])|[0-9A-Fa-f]{2})/gi; + } + return /\|([0-9](?![0-9A-F])|[0-9A-F]{2}(?![0-9A-F]))/g; +} + +function getPipeCodeLetterRegex() { + return getPipeCodeParserMode() === 'loose' ? /\|([A-Z]{2})/gi : /\|([A-Z]{2})/g; +} + /** * Check if text contains pipe codes (BBS color codes like |15, |04, etc. or special codes like |CL) */ @@ -1601,12 +1635,7 @@ function hasPipeCodes(text) { } const normalized = String(text).replace(/\|\|/g, ''); - // Match single-digit shorthand (|1), two-digit color codes (|01, |0A, |1F), - // or special letter codes (|CL, |PA, etc.). - // Requires uppercase letters for both hex and letter codes — all real BBS software - // produces uppercase pipe codes, and the case-insensitive version causes false positives - // on natural English text (e.g. |Advertise → |Ad matches as a Mystic hex color code). - return /\|(?:[0-9](?![0-9A-F])|[0-9A-F]{2}(?![0-9A-F])|[A-Z]{2}(?![A-Z]))/.test(normalized); + return getPipeCodeDetectionRegex().test(normalized); } /** @@ -1643,8 +1672,9 @@ function convertPipeCodesToAnsi(text) { 'KP', 'KR', 'KS', 'KT', 'KU', 'KD', 'GE', 'GV', 'GL', 'GR', 'GN', 'GO' ]; - text = text.replace(/\|([A-Z]{2})/g, (match, code) => { - return knownPipeCodes.includes(code) ? '' : match; + text = text.replace(getPipeCodeLetterRegex(), (match, code) => { + const normalizedCode = getPipeCodeParserMode() === 'loose' ? code.toUpperCase() : code; + return knownPipeCodes.includes(normalizedCode) ? '' : match; }); // Pipe code to ANSI color mapping @@ -1690,7 +1720,7 @@ function convertPipeCodesToAnsi(text) { // Codes use Renegade-style decimal notation: |00-|15 = foreground, |16-|23 = background. // Mystic-style hex codes (|0A = bright green, |1F = blue bg + white fg, etc.) are also // handled: codes with letters A-F are parsed as hex nibbles. - text = text.replace(/\|([0-9](?![0-9A-F])|[0-9A-F]{2}(?![0-9A-F]))/g, (match, codeStr) => { + text = text.replace(getPipeCodeColorRegex(), (match, codeStr) => { if (codeStr.length === 1) { const ansiFg = pipeToAnsiFg[parseInt(codeStr, 10)] || 37; return `\x1b[${ansiFg}m`; @@ -1787,9 +1817,12 @@ function parsePipeCodes(text) { let result = ''; let spanOpen = false; - // Pipe code pattern: |XX where XX is exactly two decimal digits (00-99). - // Greedy 2-digit match so |152 is parsed as color code |15 followed by literal "2". - const pipePattern = /\|([0-9]{2})/g; + // Pipe code pattern depends on PIPE_CODE_PARSER_MODE. + // strict: conservative uppercase-only matching with lookahead boundary. + // decimal_relaxed: greedily accepts two-digit decimal codes so |152 parses as |15 + 2 + // and |01A parses as |01 + A. + // loose: legacy permissive matcher for experiments. + const pipePattern = getPipeCodeColorRegex(); let lastIndex = 0; let match; diff --git a/public_html/js/echomail.js b/public_html/js/echomail.js index 687586a6c..d3ad96809 100644 --- a/public_html/js/echomail.js +++ b/public_html/js/echomail.js @@ -34,6 +34,13 @@ let currentConversationMessageId = null; let currentConversationSubject = ''; let currentContextMenuMessageId = null; let currentContextMenuMessageSaved = false; +const ECHOMAIL_STATS_CACHE_TTL_MS = 10000; +let statsCacheKey = null; +let statsCacheData = null; +let statsCacheFetchedAt = 0; +let statsRequestKey = null; +let statsRequestPromise = null; +let echomailRefreshPromise = null; function apiError(payload, fallback) { if (window.getApiErrorMessage) { @@ -52,6 +59,61 @@ function uiT(key, fallback, params = {}) { return fallback; } +function getStatsCacheKey() { + if (currentInterestId) { + return `interest:${currentInterestId}`; + } + return `echo:${currentEchoarea || '__all__'}`; +} + +function invalidateStatsCache() { + statsCacheKey = null; + statsCacheData = null; + statsCacheFetchedAt = 0; + statsRequestKey = null; + statsRequestPromise = null; +} + +function applyStatsToUi(data) { + console.log('Echomail stats response:', data); + $('#totalMessages').text(data.total || 0); + $('#unreadMessages').text(data.unread || 0); + $('#recentMessages').text(data.recent || 0); + + if (data.areas !== undefined) { + $('#totalAreas').text(data.areas || 0); + } else { + $('#totalAreas').text('-'); + } + + if (data.filter_counts) { + updateFilterCounts(data.filter_counts); + } +} + +function updateMessagesHeaderTitle() { + const titleEl = $('#messagesHeaderTitle'); + if (!titleEl.length) return; + + if (currentFilter === 'drafts') { + titleEl.text(uiT('ui.common.drafts', 'Drafts')); + return; + } + + const messagesLabel = uiT('ui.echoareas.messages', 'Messages'); + if (currentEchoarea) { + titleEl.text(`${currentEchoarea} ${messagesLabel}`); + return; + } + + if (currentInterestId && currentInterestName) { + titleEl.text(`${currentInterestName} ${messagesLabel}`); + return; + } + + titleEl.text(uiT('ui.echomail.recent_messages', 'Recent Messages')); +} + // Date display configuration: 'written' or 'received' // Sourced from server-side ECHOMAIL_ORDER_DATE env configuration. const USE_DATE_FIELD = (window.echomailDateField === 'written') ? 'written' : 'received'; @@ -63,9 +125,8 @@ $(document).ready(function() { const messageParam = urlParams.get('message'); requestedMessageId = messageParam && /^\d+$/.test(messageParam) ? parseInt(messageParam, 10) : null; - loadEchoareas(); - if (searchQuery) { + refreshEchomailView({ reloadMessages: false }); // Populate search input and trigger search $('#searchInput').val(searchQuery); $('#mobileSearchInput').val(searchQuery); @@ -76,8 +137,10 @@ $(document).ready(function() { if (echoPageMemory[memKey]) { currentPage = echoPageMemory[memKey]; } - loadMessages(function() { - openRequestedMessage(); + refreshEchomailView({ + messageCallback: function() { + openRequestedMessage(); + } }); } }); @@ -178,10 +241,11 @@ $(document).ready(function() { // Only refresh if the page was hidden for more than 30 seconds. if (Date.now() - _hiddenAt >= 30000) { if (!isSearchActive) { - loadMessages(null, true); + refreshEchomailView({ silentMessages: true }); + } else { + loadStats(); + loadEchoareas(); } - loadStats(); - loadEchoareas(); } _hiddenAt = null; } @@ -195,10 +259,11 @@ $(document).ready(function() { clearTimeout(_dashboardRefreshTimer); _dashboardRefreshTimer = setTimeout(function() { if (!isSearchActive) { - loadMessages(null, true); + refreshEchomailView({ silentMessages: true }); + } else { + loadStats(); + loadEchoareas(); } - loadStats(); - loadEchoareas(); }, 2000); }); @@ -242,8 +307,55 @@ function cleanupMessageModalMedia() { }); } +function refreshEchomailView(options = {}) { + const silentMessages = options.silentMessages === true; + const reloadMessages = options.reloadMessages !== false; + const reloadEchoareas = options.reloadEchoareas !== false; + const messageCallback = typeof options.messageCallback === 'function' ? options.messageCallback : null; + + if (echomailRefreshPromise) { + return echomailRefreshPromise; + } + + echomailRefreshPromise = new Promise(function(resolve) { + let pending = 0; + + function track(req) { + pending++; + if (req && typeof req.always === 'function') { + req.always(done); + } else { + done(); + } + } + + function done() { + pending--; + if (pending <= 0) { + echomailRefreshPromise = null; + resolve(); + } + } + + if (reloadEchoareas) { + track(loadEchoareas()); + } + + if (reloadMessages) { + track(loadMessages(messageCallback, silentMessages)); + } + + if (pending === 0) { + echomailRefreshPromise = null; + resolve(); + } + }); + + return echomailRefreshPromise; +} + function loadEchoareas(callback) { - $.get('/api/echoareas?subscribed_only=true') + return $.get('/api/echoareas?subscribed_only=true') .done(function(data) { allEchoareas = data.echoareas; applyEchoareaFilter(); @@ -262,6 +374,7 @@ function loadEchoareas(callback) { * If no echo is selected the bar is hidden. */ function updateEchoInfoBar() { + updateMessagesHeaderTitle(); if (!currentEchoarea) { // No specific area selected — show a generic "All Messages" bar with compose button $('#echoTitle').text(uiT('ui.common.all_messages', 'All Messages')); @@ -361,6 +474,7 @@ function renderEchoInfoBar(area, subscribed) { * @param {string} name Interest name */ function renderInterestInfoBar(name) { + updateMessagesHeaderTitle(); $('#echoTitle').text(name); $('#echoDescription').text(''); $('#echoSubscribeBtn').addClass('d-none'); @@ -392,6 +506,7 @@ function toggleSubscription() { // Update button immediately, then reload sidebar in background renderEchoInfoBar(currentEchoareaData, !subscribed); allEchoareasCache = null; + invalidateStatsCache(); loadEchoareas(); } else { btn.prop('disabled', false); @@ -735,12 +850,11 @@ function loadMessages(callback, silent = false) { if (currentFilter === 'drafts') { // Load drafts instead of regular messages - loadDrafts(); - return; + return loadDrafts(); } if (currentConversationMessageId) { - $.get(`/api/messages/echomail/message/${currentConversationMessageId}/conversation`) + return $.get(`/api/messages/echomail/message/${currentConversationMessageId}/conversation`) .done(function(data) { displayMessages(data.messages, true); updatePagination(data.pagination); @@ -769,7 +883,7 @@ function loadMessages(callback, silent = false) { url += '&threaded=true'; } - $.get(url) + return $.get(url) .done(function(data) { // If the saved page is beyond the last page, reset to page 1 and reload if (currentPage > 1 && data.messages && data.messages.length === 0 && data.pagination && data.pagination.pages < currentPage) { @@ -779,7 +893,9 @@ function loadMessages(callback, silent = false) { } displayMessages(data.messages, data.threaded || false); updatePagination(data.pagination); - updateUnreadCount(data.unreadCount || 0); + if (Object.prototype.hasOwnProperty.call(data, 'unreadCount')) { + updateUnreadCount(data.unreadCount || 0); + } // Remember the current page for this area (null = All Messages, stored as '__all__') echoPageMemory[currentEchoarea || '__all__'] = currentPage; saveEchoPositions(); @@ -795,7 +911,7 @@ function loadMessages(callback, silent = false) { } function loadDrafts() { - $.get('/api/messages/drafts?type=echomail') + return $.get('/api/messages/drafts?type=echomail') .done(function(data) { if (data.success) { displayDrafts(data.drafts); @@ -1366,6 +1482,7 @@ function setFilter(filter) { currentFilter = filter; currentPage = 1; updateFilterTabs(); + updateMessagesHeaderTitle(); loadMessages(); } @@ -1470,9 +1587,6 @@ function viewMessage(messageId) { history.pushState({modal: 'message', messageId: messageId}, '', ''); } - // Mark as read immediately - markEchomailAsRead(messageId); - $('#messageContent').html(`
@@ -1494,6 +1608,7 @@ function viewMessage(messageId) { $.get(apiUrl) .done(function(data) { displayMessageContent(data); + markEchomailAsRead(messageId); // Auto-scroll to top of modal content $('#messageModal .modal-body').scrollTop(0); }) @@ -1991,6 +2106,9 @@ function renderEchomailMessageContent(message, parsedMessage, isInAddressBook) { ${message.is_shared == 1 ? `` : ''}
+
+
+