diff --git a/CLAUDE.md b/CLAUDE.md index d375033cd..8a98802d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ BinktermPHP is a multi-protocol BBS platform built around native FTN messaging. - For version bump steps and UPGRADING doc format, invoke the `/bump-version` skill. - **UPGRADING doc content rules**: Do not include sentences stating that no configuration is needed, no sysop action is required, or no migration is required. These are filler — omit them entirely. - When creating or modifying a WebDoor, invoke the `/new-webdoor` skill. - - Write phpDoc blocks when possible + - **phpDoc requirement**: New PHP classes must include a class-level phpDoc block. New public methods should include phpDoc by default unless the signature and behavior are both trivial and self-explanatory. ## PostgreSQL Gotchas diff --git a/composer.json b/composer.json index ace40c61d..a9b6aa0c1 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "twig/twig": "^3.7", "ext-pdo": "*", "ext-pgsql": "*", + "ext-ftp": "*", "phpmailer/phpmailer": "^6.8", "chillerlan/php-qrcode": "^4.3" }, diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index a41ce3914..8dd9407e4 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -440,6 +440,9 @@ 'ui.admin_users.admin_privileges_help' => 'Administratoren können Benutzer und Systemeinstellungen verwalten', 'ui.admin_users.is_system_account' => 'Ist ein Systemkonto', 'ui.admin_users.is_system_account_help' => 'Systemkonten werden in der normalen Benutzerliste ausgeblendet und sind für interne Zwecke oder Dienste reserviert.', + 'ui.admin_users.is_bbs_account' => 'Ist ein BBS-Konto', + 'ui.admin_users.is_bbs_account_help' => 'BBS-Konten können QWK-Antworten mit dem From-Namen aus dem entfernten Paket importieren statt mit dem Namen des lokalen Kontos.', + 'ui.admin_users.is_bbs_account_badge' => 'BBS', 'ui.admin_users.email_optional_help' => 'Optional - for account recovery and notifications', 'ui.admin_users.min_8_characters' => 'Minimum 8 characters', 'ui.admin_users.username_cannot_change' => 'Benutzername cannot be changed', @@ -2453,6 +2456,10 @@ 'ui.echoareas.legend_administrative' => 'Administrative', 'ui.echoareas.legend_special_interest' => 'Special interest', 'ui.echoareas.legend_regional' => 'Regional', + 'ui.echoareas.editor_section_identity' => 'Identitat', + 'ui.echoareas.editor_section_appearance' => 'Darstellung und Posting', + 'ui.echoareas.editor_section_policy' => 'Zugang und Richtlinien', + 'ui.echoareas.editor_section_transport' => 'Transport und Weiterleitung', 'ui.echoareas.tag_required' => 'Tag *', 'ui.echoareas.tag_help' => 'Echo-Bereich tag (e.g., FIDONET.GEN, LOCAL.TEST)', 'ui.echoareas.description_required' => 'Beschreibung *', @@ -2495,6 +2502,14 @@ 'ui.echoareas.allow_media_deny' => 'Deaktiviert', 'ui.echoareas.delete_confirm_prefix' => 'Möchtest Du diesen Echo-Bereich wirklich löschen', 'ui.echoareas.delete_confirm_warning' => 'Diese Aktion kann nicht rückgängig gemacht werden und wirkt sich auf das Nachrichten-Routing aus.', + 'ui.echoareas.delete_message_handling' => 'Was soll mit den verbleibenden Nachrichten geschehen?', + 'ui.echoareas.select_delete_action' => 'Aktion auswählen', + 'ui.echoareas.delete_messages_action' => 'Sie löschen', + 'ui.echoareas.move_messages_action' => 'In einen anderen Bereich verschieben', + 'ui.echoareas.delete_move_note' => 'Das Verschieben weist Nachrichten nur lokal neu zu. Alte Nachrichten werden nicht erneut weitergeleitet oder veröffentlicht.', + 'ui.echoareas.move_messages_target' => 'Nachrichten verschieben nach', + 'ui.echoareas.select_target_area' => 'Zielbereich auswählen', + 'ui.echoareas.delete_targets_failed' => 'Zielbereiche konnten nicht geladen werden', 'ui.echoareas.none_found' => 'Keine Echo-Bereiche gefunden', 'ui.echoareas.tag' => 'Tag', 'ui.echoareas.messages' => 'Nachrichten', @@ -4940,6 +4955,8 @@ 'ui.admin.networks.add_network' => 'Add Network', 'ui.admin.networks.edit_network' => 'Edit Network', 'ui.admin.networks.type' => 'Type', + 'ui.admin.networks.type_fidonet' => 'FidoNet', + 'ui.admin.networks.type_qwk' => 'QWK', 'ui.admin.networks.domain' => 'Domain', 'ui.admin.networks.name' => 'Name', 'ui.admin.networks.description' => 'Description', @@ -4959,4 +4976,66 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.admin.networks.search_placeholder' => 'Search domain, name, description, website, or uplink...', + 'ui.admin.networks.none' => 'No networks found', + 'ui.qwk.uplinks.manage' => 'QWK Mailboxes', + 'ui.qwk.uplinks.list_title' => 'Configured Mailboxes', + 'ui.qwk.uplinks.add_title' => 'Add QWK Mailbox', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Mailbox', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.passive_mode' => 'Passive Mode', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK mailboxes', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK mailbox', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK mailbox', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK mailbox', + 'ui.qwk.uplinks.none' => 'No QWK mailboxes configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK mailbox saved', + 'ui.qwk.uplinks.deleted' => 'QWK mailbox deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK mailbox?', + 'ui.qwk.uplinks.polled' => 'QWK mailbox polled', + 'ui.qwk.uplinks.polling' => 'Polling QWK mailbox...', + 'ui.qwk.uplinks.result_download' => 'Downloaded packet', + 'ui.qwk.uplinks.result_imported' => 'Imported', + 'ui.qwk.uplinks.result_skipped' => 'Skipped', + 'ui.qwk.uplinks.result_rep_created' => 'Built REP packet', + 'ui.qwk.uplinks.result_uploaded' => 'Uploaded REP', + 'ui.qwk.uplinks.result_dry_run' => 'Skipped (dry run)', + 'ui.qwk.echoarea.subscriptions_title' => 'Transportverbindungen', + 'ui.qwk.echoarea.subscriptions_help' => 'Verbinden Sie diesen Echo-Bereich mit externen Transport-Endpunkten wie QWK-Mailboxen.', + 'ui.qwk.echoarea.relay_title' => 'Importweiterleitung', + 'ui.qwk.echoarea.relay_help' => 'Steuert, ob über Transport importierte Nachrichten an andere verbundene Transporte dieses Bereichs weitergeleitet werden.', + 'ui.qwk.echoarea.relay_mode_none' => 'Keine Weiterleitung', + 'ui.qwk.echoarea.relay_mode_none_short' => 'Aus', + 'ui.qwk.echoarea.relay_mode_auto' => 'Automatisch an alle anderen verbundenen Transporte weiterleiten', + 'ui.qwk.echoarea.relay_mode_auto_short' => 'Auto', + 'ui.qwk.echoarea.relay_mode_manual' => 'Manuelle Weiterleitungsregeln', + 'ui.qwk.echoarea.relay_rules_title' => 'Manuelle Weiterleitungsregeln', + 'ui.qwk.echoarea.relay_rules_help' => 'Wählen Sie, welche transport-importierten Nachrichten an andere verbundene Transporte weitergeleitet werden dürfen.', + 'ui.qwk.echoarea.relay_rules_unavailable' => 'Verbinden Sie mehr als einen Transport, um manuelle Weiterleitungsregeln zu nutzen.', + 'ui.qwk.echoarea.none_subscriptions' => 'Noch keine Transportverbindungen', + 'ui.qwk.echoarea.none_gates' => 'Keine Gates konfiguriert', + 'ui.qwk.echoarea.local_only_notice' => 'Externe Transport-Weiterleitung ist deaktiviert, solange dieser Bereich als Local Only markiert ist.', + 'ui.qwk.echoarea.summary_subscriptions' => '{count} verknupft', + 'ui.qwk.echoarea.summary_subscriptions_none' => 'Keine', + 'ui.qwk.echoarea.summary_gates' => '{count} konfiguriert', + 'ui.qwk.echoarea.summary_gates_none' => 'Keine', + 'ui.qwk.echoarea.summary_rules' => '{count} Regeln', + 'ui.qwk.echoarea.summary_rules_none' => 'Manuell', + 'ui.qwk.echoarea.transport_ftn' => 'FTN', + 'ui.qwk.echoarea.transport_qwk' => 'QWK', + 'ui.qwk.echoarea.gates_title' => 'Gates', + 'ui.qwk.echoarea.gates_help' => 'Mirror this area into other local echo areas.', + 'ui.qwk.echoarea.none_configured' => 'None configured', + 'ui.qwk.echoarea.uplink_label' => 'Verbindung', + 'ui.qwk.echoarea.select_uplink' => 'Verbindung auswahlen', + 'ui.qwk.echoarea.conference_tag' => 'Remote-Tag', + 'ui.qwk.echoarea.conference_number' => 'Remote-ID', + 'ui.qwk.echoarea.gate_target' => 'Target Area', + 'ui.qwk.echoarea.select_gate_target' => 'Select target area', + 'ui.qwk.echoarea.bidirectional' => 'Bidirectional', + 'ui.qwk.echoarea_config_saved' => 'QWK subscriptions and gates saved', ]; diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index da59425f8..ef4323f01 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -139,6 +139,10 @@ 'errors.echoareas.not_found_or_unchanged' => 'Echo-Bereich nicht gefunden or no changes made', 'errors.echoareas.update_failed' => 'Echo-Bereich konnte nicht aktualisiert werden', 'errors.echoareas.delete_blocked_has_messages' => 'Cannot delete echo area with existing messages', + 'errors.echoareas.delete_action_required' => 'Wähle vor dem Löschen dieses Echo-Bereichs aus, was mit den verbleibenden Nachrichten geschehen soll', + 'errors.echoareas.delete_invalid_action' => 'Ungültige Löschaktion ausgewählt', + 'errors.echoareas.delete_move_target_required' => 'Wähle einen Ziel-Echo-Bereich zum Verschieben der verbleibenden Nachrichten aus', + 'errors.echoareas.delete_move_target_invalid' => 'Der ausgewählte Ziel-Echo-Bereich ist ungültig', 'errors.echoareas.delete_failed' => 'Echo-Bereich konnte nicht gelöscht werden', // File Areas @@ -722,4 +726,7 @@ 'errors.meshcore.not_found' => 'Kontakt nicht gefunden.', 'errors.meshcore.qr_unrecognized' => 'Unbekanntes QR-Code-Format.', 'errors.meshcore.qr_camera_denied' => 'Kamerazugriff verweigert.', + 'errors.qwk.uplink_not_found' => 'QWK mailbox not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK mailbox configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK mailbox', ]; diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 913f1ba01..c8c108653 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -437,6 +437,9 @@ 'ui.admin_users.admin_privileges_help' => 'Admins can manage users and system settings', 'ui.admin_users.is_system_account' => 'Is System Account', 'ui.admin_users.is_system_account_help' => 'System accounts are hidden from the normal user list and reserved for internal or service use.', + 'ui.admin_users.is_bbs_account' => 'Is BBS Account', + 'ui.admin_users.is_bbs_account_help' => 'BBS accounts may import QWK replies using the remote packet\'s From name instead of the local account name.', + 'ui.admin_users.is_bbs_account_badge' => 'BBS', 'ui.admin_users.email_optional_help' => 'Optional - for account recovery and notifications', 'ui.admin_users.min_8_characters' => 'Minimum 8 characters', 'ui.admin_users.username_cannot_change' => 'Username cannot be changed', @@ -2471,6 +2474,10 @@ 'ui.echoareas.legend_administrative' => 'Administrative', 'ui.echoareas.legend_special_interest' => 'Special interest', 'ui.echoareas.legend_regional' => 'Regional', + 'ui.echoareas.editor_section_identity' => 'Identity', + 'ui.echoareas.editor_section_appearance' => 'Appearance and posting', + 'ui.echoareas.editor_section_policy' => 'Access and policy', + 'ui.echoareas.editor_section_transport' => 'Transport and relay', 'ui.echoareas.tag_required' => 'Tag *', 'ui.echoareas.tag_help' => 'Echo area tag (e.g., FIDONET.GEN, LOCAL.TEST)', 'ui.echoareas.description_required' => 'Description *', @@ -2513,6 +2520,14 @@ 'ui.echoareas.allow_media_deny' => 'Disabled', 'ui.echoareas.delete_confirm_prefix' => 'Are you sure you want to delete the echo area', 'ui.echoareas.delete_confirm_warning' => 'This action cannot be undone and will affect message routing.', + 'ui.echoareas.delete_message_handling' => 'What should happen to the remaining messages?', + 'ui.echoareas.select_delete_action' => 'Select an action', + 'ui.echoareas.delete_messages_action' => 'Delete them', + 'ui.echoareas.move_messages_action' => 'Move them to another area', + 'ui.echoareas.delete_move_note' => 'Moving messages only reassigns them locally. It does not re-gate or republish old messages.', + 'ui.echoareas.move_messages_target' => 'Move messages to', + 'ui.echoareas.select_target_area' => 'Select target area', + 'ui.echoareas.delete_targets_failed' => 'Failed to load target echo areas', 'ui.echoareas.none_found' => 'No echo areas found', 'ui.echoareas.tag' => 'Tag', 'ui.echoareas.messages' => 'Messages', @@ -4962,6 +4977,8 @@ 'ui.admin.networks.add_network' => 'Add Network', 'ui.admin.networks.edit_network' => 'Edit Network', 'ui.admin.networks.type' => 'Type', + 'ui.admin.networks.type_fidonet' => 'FidoNet', + 'ui.admin.networks.type_qwk' => 'QWK', 'ui.admin.networks.domain' => 'Domain', 'ui.admin.networks.name' => 'Name', 'ui.admin.networks.description' => 'Description', @@ -4981,4 +4998,66 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.admin.networks.search_placeholder' => 'Search domain, name, description, website, or uplink...', + 'ui.admin.networks.none' => 'No networks found', + 'ui.qwk.uplinks.manage' => 'QWK Mailboxes', + 'ui.qwk.uplinks.list_title' => 'Configured Mailboxes', + 'ui.qwk.uplinks.add_title' => 'Add QWK Mailbox', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Mailbox', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.passive_mode' => 'Passive Mode', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK mailboxes', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK mailbox', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK mailbox', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK mailbox', + 'ui.qwk.uplinks.none' => 'No QWK mailboxes configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK mailbox saved', + 'ui.qwk.uplinks.deleted' => 'QWK mailbox deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK mailbox?', + 'ui.qwk.uplinks.polled' => 'QWK mailbox polled', + 'ui.qwk.uplinks.polling' => 'Polling QWK mailbox...', + 'ui.qwk.uplinks.result_download' => 'Downloaded packet', + 'ui.qwk.uplinks.result_imported' => 'Imported', + 'ui.qwk.uplinks.result_skipped' => 'Skipped', + 'ui.qwk.uplinks.result_rep_created' => 'Built REP packet', + 'ui.qwk.uplinks.result_uploaded' => 'Uploaded REP', + 'ui.qwk.uplinks.result_dry_run' => 'Skipped (dry run)', + 'ui.qwk.echoarea.subscriptions_title' => 'Transport Connections', + 'ui.qwk.echoarea.subscriptions_help' => 'Connect this echo area to external transport endpoints such as QWK mailboxes.', + 'ui.qwk.echoarea.relay_title' => 'Imported Message Relay', + 'ui.qwk.echoarea.relay_help' => 'Controls whether transport-imported messages are relayed to other connected transports for this area.', + 'ui.qwk.echoarea.relay_mode_none' => 'No relay', + 'ui.qwk.echoarea.relay_mode_none_short' => 'Off', + 'ui.qwk.echoarea.relay_mode_auto' => 'Auto relay to all other connected transports', + 'ui.qwk.echoarea.relay_mode_auto_short' => 'Auto', + 'ui.qwk.echoarea.relay_mode_manual' => 'Manual relay rules', + 'ui.qwk.echoarea.relay_rules_title' => 'Manual Relay Rules', + 'ui.qwk.echoarea.relay_rules_help' => 'Choose which transport-imported messages may relay to other connected transports.', + 'ui.qwk.echoarea.relay_rules_unavailable' => 'Connect more than one transport to use manual relay rules.', + 'ui.qwk.echoarea.none_subscriptions' => 'No transport connections yet', + 'ui.qwk.echoarea.none_gates' => 'No gates configured', + 'ui.qwk.echoarea.local_only_notice' => 'External transport relay is disabled while this area is marked Local Only.', + 'ui.qwk.echoarea.summary_subscriptions' => '{count} linked', + 'ui.qwk.echoarea.summary_subscriptions_none' => 'None', + 'ui.qwk.echoarea.summary_gates' => '{count} configured', + 'ui.qwk.echoarea.summary_gates_none' => 'None', + 'ui.qwk.echoarea.summary_rules' => '{count} rules', + 'ui.qwk.echoarea.summary_rules_none' => 'Manual', + 'ui.qwk.echoarea.transport_ftn' => 'FTN', + 'ui.qwk.echoarea.transport_qwk' => 'QWK', + 'ui.qwk.echoarea.gates_title' => 'Gates', + 'ui.qwk.echoarea.gates_help' => 'Mirror this area into other local echo areas.', + 'ui.qwk.echoarea.none_configured' => 'None configured', + 'ui.qwk.echoarea.uplink_label' => 'Connection', + 'ui.qwk.echoarea.select_uplink' => 'Select connection', + 'ui.qwk.echoarea.conference_tag' => 'Remote Tag', + 'ui.qwk.echoarea.conference_number' => 'Remote ID', + 'ui.qwk.echoarea.gate_target' => 'Target Area', + 'ui.qwk.echoarea.select_gate_target' => 'Select target area', + 'ui.qwk.echoarea.bidirectional' => 'Bidirectional', + 'ui.qwk.echoarea_config_saved' => 'QWK subscriptions and gates saved', ]; diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index bd758a978..817ae4d52 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -139,6 +139,10 @@ 'errors.echoareas.not_found_or_unchanged' => 'Echo area not found or no changes made', 'errors.echoareas.update_failed' => 'Failed to update echo area', 'errors.echoareas.delete_blocked_has_messages' => 'Cannot delete echo area with existing messages', + 'errors.echoareas.delete_action_required' => 'Choose what to do with the remaining messages before deleting this echo area', + 'errors.echoareas.delete_invalid_action' => 'Invalid delete action selected', + 'errors.echoareas.delete_move_target_required' => 'Select a target echo area to move the remaining messages', + 'errors.echoareas.delete_move_target_invalid' => 'Selected target echo area is invalid', 'errors.echoareas.delete_failed' => 'Failed to delete echo area', // File Areas @@ -722,4 +726,7 @@ 'errors.meshcore.not_found' => 'Contact not found.', 'errors.meshcore.qr_unrecognized' => 'Unrecognized QR code format.', 'errors.meshcore.qr_camera_denied' => 'Camera access denied.', + 'errors.qwk.uplink_not_found' => 'QWK mailbox not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK mailbox configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK mailbox', ]; diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index ebd0a95e1..dc2f590ae 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -437,6 +437,9 @@ 'ui.admin_users.admin_privileges_help' => 'Los administradores pueden gestionar usuarios y configuracion del sistema', 'ui.admin_users.is_system_account' => 'Es cuenta del sistema', 'ui.admin_users.is_system_account_help' => 'Las cuentas del sistema se ocultan de la lista normal de usuarios y se reservan para uso interno o de servicios.', + 'ui.admin_users.is_bbs_account' => 'Es cuenta BBS', + 'ui.admin_users.is_bbs_account_help' => 'Las cuentas BBS pueden importar respuestas QWK usando el nombre From del paquete remoto en lugar del nombre de la cuenta local.', + 'ui.admin_users.is_bbs_account_badge' => 'BBS', 'ui.admin_users.email_optional_help' => 'Opcional: para recuperacion de cuenta y notificaciones', 'ui.admin_users.min_8_characters' => 'Minimo 8 caracteres', 'ui.admin_users.username_cannot_change' => 'El usuario no se puede cambiar', @@ -2453,6 +2456,10 @@ 'ui.echoareas.legend_administrative' => 'Administrativo', 'ui.echoareas.legend_special_interest' => 'Interes especial', 'ui.echoareas.legend_regional' => 'Regional', + 'ui.echoareas.editor_section_identity' => 'Identidad', + 'ui.echoareas.editor_section_appearance' => 'Apariencia y publicacion', + 'ui.echoareas.editor_section_policy' => 'Acceso y politica', + 'ui.echoareas.editor_section_transport' => 'Transporte y relevo', 'ui.echoareas.tag_required' => 'Etiqueta *', 'ui.echoareas.tag_help' => 'Etiqueta del area de eco (ej.: FIDONET.GEN, LOCAL.TEST)', 'ui.echoareas.description_required' => 'Descripcion *', @@ -2488,6 +2495,14 @@ 'ui.echoareas.allow_media_deny' => 'Desactivado', 'ui.echoareas.delete_confirm_prefix' => 'Esta seguro de que desea eliminar el area de eco', 'ui.echoareas.delete_confirm_warning' => 'Esta accion no se puede deshacer y afectara el enrutamiento de mensajes.', + 'ui.echoareas.delete_message_handling' => 'Que debe pasar con los mensajes restantes?', + 'ui.echoareas.select_delete_action' => 'Seleccione una accion', + 'ui.echoareas.delete_messages_action' => 'Eliminarlos', + 'ui.echoareas.move_messages_action' => 'Moverlos a otra area', + 'ui.echoareas.delete_move_note' => 'Mover mensajes solo los reasigna localmente. No los vuelve a enrutar ni a republicar.', + 'ui.echoareas.move_messages_target' => 'Mover mensajes a', + 'ui.echoareas.select_target_area' => 'Seleccionar area de destino', + 'ui.echoareas.delete_targets_failed' => 'No se pudieron cargar las areas de destino', 'ui.echoareas.none_found' => 'No se encontraron areas de eco', 'ui.echoareas.tag' => 'Etiqueta', 'ui.echoareas.messages' => 'Mensajes', @@ -4928,6 +4943,8 @@ 'ui.admin.networks.add_network' => 'Add Network', 'ui.admin.networks.edit_network' => 'Edit Network', 'ui.admin.networks.type' => 'Type', + 'ui.admin.networks.type_fidonet' => 'FidoNet', + 'ui.admin.networks.type_qwk' => 'QWK', 'ui.admin.networks.domain' => 'Domain', 'ui.admin.networks.name' => 'Name', 'ui.admin.networks.description' => 'Description', @@ -4947,4 +4964,66 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.admin.networks.search_placeholder' => 'Search domain, name, description, website, or uplink...', + 'ui.admin.networks.none' => 'No networks found', + 'ui.qwk.uplinks.manage' => 'QWK Mailboxes', + 'ui.qwk.uplinks.list_title' => 'Configured Mailboxes', + 'ui.qwk.uplinks.add_title' => 'Add QWK Mailbox', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Mailbox', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.passive_mode' => 'Passive Mode', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK mailboxes', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK mailbox', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK mailbox', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK mailbox', + 'ui.qwk.uplinks.none' => 'No QWK mailboxes configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK mailbox saved', + 'ui.qwk.uplinks.deleted' => 'QWK mailbox deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK mailbox?', + 'ui.qwk.uplinks.polled' => 'QWK mailbox polled', + 'ui.qwk.uplinks.polling' => 'Polling QWK mailbox...', + 'ui.qwk.uplinks.result_download' => 'Downloaded packet', + 'ui.qwk.uplinks.result_imported' => 'Imported', + 'ui.qwk.uplinks.result_skipped' => 'Skipped', + 'ui.qwk.uplinks.result_rep_created' => 'Built REP packet', + 'ui.qwk.uplinks.result_uploaded' => 'Uploaded REP', + 'ui.qwk.uplinks.result_dry_run' => 'Skipped (dry run)', + 'ui.qwk.echoarea.subscriptions_title' => 'Conexiones de transporte', + 'ui.qwk.echoarea.subscriptions_help' => 'Conecta esta area de eco a puntos finales de transporte externos como buzones QWK.', + 'ui.qwk.echoarea.relay_title' => 'Relevo de mensajes importados', + 'ui.qwk.echoarea.relay_help' => 'Controla si los mensajes importados por transporte se reenvian a otros transportes conectados para esta area.', + 'ui.qwk.echoarea.relay_mode_none' => 'Sin relevo', + 'ui.qwk.echoarea.relay_mode_none_short' => 'Off', + 'ui.qwk.echoarea.relay_mode_auto' => 'Reenviar automaticamente a todos los demas transportes conectados', + 'ui.qwk.echoarea.relay_mode_auto_short' => 'Auto', + 'ui.qwk.echoarea.relay_mode_manual' => 'Reglas manuales de relevo', + 'ui.qwk.echoarea.relay_rules_title' => 'Reglas manuales de relevo', + 'ui.qwk.echoarea.relay_rules_help' => 'Elige que mensajes importados por transporte pueden reenviarse a otros transportes conectados.', + 'ui.qwk.echoarea.relay_rules_unavailable' => 'Conecta mas de un transporte para usar reglas manuales de relevo.', + 'ui.qwk.echoarea.none_subscriptions' => 'Todavia no hay conexiones de transporte', + 'ui.qwk.echoarea.none_gates' => 'No hay gates configurados', + 'ui.qwk.echoarea.local_only_notice' => 'El relevo de transporte externo esta deshabilitado mientras esta area este marcada como Solo local.', + 'ui.qwk.echoarea.summary_subscriptions' => '{count} enlazadas', + 'ui.qwk.echoarea.summary_subscriptions_none' => 'Ninguna', + 'ui.qwk.echoarea.summary_gates' => '{count} configuradas', + 'ui.qwk.echoarea.summary_gates_none' => 'Ninguna', + 'ui.qwk.echoarea.summary_rules' => '{count} reglas', + 'ui.qwk.echoarea.summary_rules_none' => 'Manual', + 'ui.qwk.echoarea.transport_ftn' => 'FTN', + 'ui.qwk.echoarea.transport_qwk' => 'QWK', + 'ui.qwk.echoarea.gates_title' => 'Gates', + 'ui.qwk.echoarea.gates_help' => 'Mirror this area into other local echo areas.', + 'ui.qwk.echoarea.none_configured' => 'None configured', + 'ui.qwk.echoarea.uplink_label' => 'Conexion', + 'ui.qwk.echoarea.select_uplink' => 'Seleccionar conexion', + 'ui.qwk.echoarea.conference_tag' => 'Etiqueta remota', + 'ui.qwk.echoarea.conference_number' => 'ID remota', + 'ui.qwk.echoarea.gate_target' => 'Target Area', + 'ui.qwk.echoarea.select_gate_target' => 'Select target area', + 'ui.qwk.echoarea.bidirectional' => 'Bidirectional', + 'ui.qwk.echoarea_config_saved' => 'QWK subscriptions and gates saved', ]; diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index ec54a6da7..05ccb83bd 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -139,6 +139,10 @@ 'errors.echoareas.not_found_or_unchanged' => 'Area de eco no encontrada o sin cambios', 'errors.echoareas.update_failed' => 'No se pudo actualizar el area de eco', 'errors.echoareas.delete_blocked_has_messages' => 'No se puede eliminar un area de eco con mensajes existentes', + 'errors.echoareas.delete_action_required' => 'Elija que hacer con los mensajes restantes antes de eliminar esta area de eco', + 'errors.echoareas.delete_invalid_action' => 'La accion de eliminacion seleccionada no es valida', + 'errors.echoareas.delete_move_target_required' => 'Seleccione un area de eco de destino para mover los mensajes restantes', + 'errors.echoareas.delete_move_target_invalid' => 'El area de eco de destino seleccionada no es valida', 'errors.echoareas.delete_failed' => 'No se pudo eliminar el area de eco', // File Areas @@ -720,4 +724,7 @@ '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.qwk.uplink_not_found' => 'QWK mailbox not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK mailbox configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK mailbox', ]; diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index 822aa669a..31cf591ad 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -400,6 +400,9 @@ 'ui.admin_users.admin_privileges_help' => 'Les administrateurs peuvent gérer les utilisateurs et les paramètres système', 'ui.admin_users.is_system_account' => 'Est un compte système', 'ui.admin_users.is_system_account_help' => 'Les comptes système sont masqués de la liste normale des utilisateurs et réservés à un usage interne ou de service.', + 'ui.admin_users.is_bbs_account' => 'Est un compte BBS', + 'ui.admin_users.is_bbs_account_help' => 'Les comptes BBS peuvent importer des réponses QWK en utilisant le nom From du paquet distant au lieu du nom du compte local.', + 'ui.admin_users.is_bbs_account_badge' => 'BBS', 'ui.admin_users.email_optional_help' => 'Facultatif - pour la récupération de compte et les notifications', 'ui.admin_users.min_8_characters' => 'Minimum 8 caractères', 'ui.admin_users.username_cannot_change' => 'Le nom d\'utilisateur ne peut pas être modifié', @@ -2018,6 +2021,10 @@ 'ui.echoareas.legend_administrative' => 'Administratif', 'ui.echoareas.legend_special_interest' => 'Intérêt particulier', 'ui.echoareas.legend_regional' => 'Régional', + 'ui.echoareas.editor_section_identity' => 'Identite', + 'ui.echoareas.editor_section_appearance' => 'Apparence et publication', + 'ui.echoareas.editor_section_policy' => 'Acces et politique', + 'ui.echoareas.editor_section_transport' => 'Transport et relais', 'ui.echoareas.tag_required' => 'Étiquette *', 'ui.echoareas.tag_help' => 'Étiquette de la zone echo (ex. : FIDONET.GEN, LOCAL.TEST)', 'ui.echoareas.description_required' => 'Description *', @@ -2053,6 +2060,14 @@ 'ui.echoareas.allow_media_deny' => 'Désactivé', 'ui.echoareas.delete_confirm_prefix' => 'Êtes-vous sûr de vouloir supprimer la zone echo', 'ui.echoareas.delete_confirm_warning' => 'Cette action est irréversible et affectera le routage des messages.', + 'ui.echoareas.delete_message_handling' => 'Que faire des messages restants ?', + 'ui.echoareas.select_delete_action' => 'Sélectionnez une action', + 'ui.echoareas.delete_messages_action' => 'Les supprimer', + 'ui.echoareas.move_messages_action' => 'Les déplacer vers une autre zone', + 'ui.echoareas.delete_move_note' => 'Le déplacement des messages les réaffecte seulement localement. Il ne les repasse pas par les passerelles et ne les republie pas.', + 'ui.echoareas.move_messages_target' => 'Déplacer les messages vers', + 'ui.echoareas.select_target_area' => 'Sélectionner la zone de destination', + 'ui.echoareas.delete_targets_failed' => 'Impossible de charger les zones de destination', 'ui.echoareas.none_found' => 'Aucune zone echo trouvée', 'ui.echoareas.tag' => 'Étiquette', 'ui.echoareas.messages' => 'Messages', @@ -4867,6 +4882,8 @@ 'ui.admin.networks.add_network' => 'Add Network', 'ui.admin.networks.edit_network' => 'Edit Network', 'ui.admin.networks.type' => 'Type', + 'ui.admin.networks.type_fidonet' => 'FidoNet', + 'ui.admin.networks.type_qwk' => 'QWK', 'ui.admin.networks.domain' => 'Domain', 'ui.admin.networks.name' => 'Name', 'ui.admin.networks.description' => 'Description', @@ -4886,4 +4903,66 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.admin.networks.search_placeholder' => 'Search domain, name, description, website, or uplink...', + 'ui.admin.networks.none' => 'No networks found', + 'ui.qwk.uplinks.manage' => 'QWK Mailboxes', + 'ui.qwk.uplinks.list_title' => 'Configured Mailboxes', + 'ui.qwk.uplinks.add_title' => 'Add QWK Mailbox', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Mailbox', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.passive_mode' => 'Passive Mode', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK mailboxes', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK mailbox', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK mailbox', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK mailbox', + 'ui.qwk.uplinks.none' => 'No QWK mailboxes configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK mailbox saved', + 'ui.qwk.uplinks.deleted' => 'QWK mailbox deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK mailbox?', + 'ui.qwk.uplinks.polled' => 'QWK mailbox polled', + 'ui.qwk.uplinks.polling' => 'Polling QWK mailbox...', + 'ui.qwk.uplinks.result_download' => 'Downloaded packet', + 'ui.qwk.uplinks.result_imported' => 'Imported', + 'ui.qwk.uplinks.result_skipped' => 'Skipped', + 'ui.qwk.uplinks.result_rep_created' => 'Built REP packet', + 'ui.qwk.uplinks.result_uploaded' => 'Uploaded REP', + 'ui.qwk.uplinks.result_dry_run' => 'Skipped (dry run)', + 'ui.qwk.echoarea.subscriptions_title' => 'Connexions de transport', + 'ui.qwk.echoarea.subscriptions_help' => 'Connecte cette zone echo a des points de transport externes comme des boites QWK.', + 'ui.qwk.echoarea.relay_title' => 'Relais des messages importes', + 'ui.qwk.echoarea.relay_help' => 'Controle si les messages importes par un transport sont relayes vers les autres transports connectes pour cette zone.', + 'ui.qwk.echoarea.relay_mode_none' => 'Aucun relais', + 'ui.qwk.echoarea.relay_mode_none_short' => 'Off', + 'ui.qwk.echoarea.relay_mode_auto' => 'Relayer automatiquement vers tous les autres transports connectes', + 'ui.qwk.echoarea.relay_mode_auto_short' => 'Auto', + 'ui.qwk.echoarea.relay_mode_manual' => 'Regles de relais manuelles', + 'ui.qwk.echoarea.relay_rules_title' => 'Regles de relais manuelles', + 'ui.qwk.echoarea.relay_rules_help' => 'Choisissez quels messages importes par transport peuvent etre relayes vers les autres transports connectes.', + 'ui.qwk.echoarea.relay_rules_unavailable' => 'Connectez plus d\'un transport pour utiliser les regles de relais manuelles.', + 'ui.qwk.echoarea.none_subscriptions' => 'Aucune connexion de transport pour le moment', + 'ui.qwk.echoarea.none_gates' => 'Aucun gate configure', + 'ui.qwk.echoarea.local_only_notice' => 'Le relais de transport externe est desactive tant que cette zone est marquee Local Only.', + 'ui.qwk.echoarea.summary_subscriptions' => '{count} lies', + 'ui.qwk.echoarea.summary_subscriptions_none' => 'Aucun', + 'ui.qwk.echoarea.summary_gates' => '{count} configures', + 'ui.qwk.echoarea.summary_gates_none' => 'Aucun', + 'ui.qwk.echoarea.summary_rules' => '{count} regles', + 'ui.qwk.echoarea.summary_rules_none' => 'Manuel', + 'ui.qwk.echoarea.transport_ftn' => 'FTN', + 'ui.qwk.echoarea.transport_qwk' => 'QWK', + 'ui.qwk.echoarea.gates_title' => 'Gates', + 'ui.qwk.echoarea.gates_help' => 'Mirror this area into other local echo areas.', + 'ui.qwk.echoarea.none_configured' => 'None configured', + 'ui.qwk.echoarea.uplink_label' => 'Connexion', + 'ui.qwk.echoarea.select_uplink' => 'Selectionner une connexion', + 'ui.qwk.echoarea.conference_tag' => 'Tag distant', + 'ui.qwk.echoarea.conference_number' => 'ID distante', + 'ui.qwk.echoarea.gate_target' => 'Target Area', + 'ui.qwk.echoarea.select_gate_target' => 'Select target area', + 'ui.qwk.echoarea.bidirectional' => 'Bidirectional', + 'ui.qwk.echoarea_config_saved' => 'QWK subscriptions and gates saved', ]; diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index a72f20ba5..3bb2336e4 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -105,6 +105,10 @@ 'errors.echoareas.not_found_or_unchanged' => 'Zone echo introuvable ou aucune modification effectuée', 'errors.echoareas.update_failed' => 'Échec de la mise à jour de la zone echo', 'errors.echoareas.delete_blocked_has_messages' => 'Impossible de supprimer une zone echo contenant des messages', + 'errors.echoareas.delete_action_required' => 'Choisissez quoi faire des messages restants avant de supprimer cette zone echo', + 'errors.echoareas.delete_invalid_action' => 'Action de suppression invalide', + 'errors.echoareas.delete_move_target_required' => 'Sélectionnez une zone echo de destination pour déplacer les messages restants', + 'errors.echoareas.delete_move_target_invalid' => 'La zone echo de destination sélectionnée est invalide', 'errors.echoareas.delete_failed' => 'Échec de la suppression de la zone echo', 'errors.fileareas.not_found' => 'Zone de fichiers introuvable', 'errors.fileareas.create_failed' => 'Échec de la création de la zone de fichiers', @@ -679,6 +683,9 @@ '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.qwk.uplink_not_found' => 'QWK mailbox not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK mailbox configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK mailbox', ]; diff --git a/config/i18n/it/common.php b/config/i18n/it/common.php index ba37f1a7f..dbf078335 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -437,6 +437,9 @@ 'ui.admin_users.admin_privileges_help' => 'Gli amministratori possono gestire utenti e impostazioni di sistema', 'ui.admin_users.is_system_account' => 'È un account di sistema', 'ui.admin_users.is_system_account_help' => 'Gli account di sistema sono nascosti dalla normale lista utenti e riservati a uso interno o di servizio.', + 'ui.admin_users.is_bbs_account' => 'È un account BBS', + 'ui.admin_users.is_bbs_account_help' => 'Gli account BBS possono importare risposte QWK usando il nome From del pacchetto remoto invece del nome dell\'account locale.', + 'ui.admin_users.is_bbs_account_badge' => 'BBS', 'ui.admin_users.email_optional_help' => 'Opzionale - per recupero account e notifiche', 'ui.admin_users.min_8_characters' => 'Minimo 8 caratteri', 'ui.admin_users.username_cannot_change' => 'Il nome utente non può essere modificato', @@ -2453,6 +2456,10 @@ 'ui.echoareas.legend_administrative' => 'Amministrativo', 'ui.echoareas.legend_special_interest' => 'Interesse speciale', 'ui.echoareas.legend_regional' => 'Regionale', + 'ui.echoareas.editor_section_identity' => 'Identita', + 'ui.echoareas.editor_section_appearance' => 'Aspetto e pubblicazione', + 'ui.echoareas.editor_section_policy' => 'Accesso e policy', + 'ui.echoareas.editor_section_transport' => 'Trasporto e relay', 'ui.echoareas.tag_required' => 'Tag *', 'ui.echoareas.tag_help' => 'Tag area echo (es. FIDONET.GEN, LOCAL.TEST)', 'ui.echoareas.description_required' => 'Descrizione *', @@ -2495,6 +2502,14 @@ 'ui.echoareas.allow_media_inherit' => 'Eredita dalla rete', 'ui.echoareas.delete_confirm_prefix' => 'Sei sicuro di voler eliminare l’area echo', 'ui.echoareas.delete_confirm_warning' => 'Questa azione non può essere annullata e influirà sul routing dei messaggi.', + 'ui.echoareas.delete_message_handling' => 'Cosa deve accadere ai messaggi rimanenti?', + 'ui.echoareas.select_delete_action' => 'Seleziona un\'azione', + 'ui.echoareas.delete_messages_action' => 'Eliminali', + 'ui.echoareas.move_messages_action' => 'Spostali in un\'altra area', + 'ui.echoareas.delete_move_note' => 'Lo spostamento riassegna i messaggi solo localmente. Non li reinstrada e non li ripubblica.', + 'ui.echoareas.move_messages_target' => 'Sposta i messaggi in', + 'ui.echoareas.select_target_area' => 'Seleziona area di destinazione', + 'ui.echoareas.delete_targets_failed' => 'Impossibile caricare le aree di destinazione', 'ui.echoareas.none_found' => 'Nessuna area echo trovata', 'ui.echoareas.tag' => 'Tag', 'ui.echoareas.messages' => 'Messaggi', @@ -4925,6 +4940,8 @@ 'ui.admin.networks.add_network' => 'Add Network', 'ui.admin.networks.edit_network' => 'Edit Network', 'ui.admin.networks.type' => 'Type', + 'ui.admin.networks.type_fidonet' => 'FidoNet', + 'ui.admin.networks.type_qwk' => 'QWK', 'ui.admin.networks.domain' => 'Domain', 'ui.admin.networks.name' => 'Name', 'ui.admin.networks.description' => 'Description', @@ -4944,4 +4961,66 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.admin.networks.search_placeholder' => 'Search domain, name, description, website, or uplink...', + 'ui.admin.networks.none' => 'No networks found', + 'ui.qwk.uplinks.manage' => 'QWK Mailboxes', + 'ui.qwk.uplinks.list_title' => 'Configured Mailboxes', + 'ui.qwk.uplinks.add_title' => 'Add QWK Mailbox', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Mailbox', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.passive_mode' => 'Passive Mode', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK mailboxes', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK mailbox', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK mailbox', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK mailbox', + 'ui.qwk.uplinks.none' => 'No QWK mailboxes configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK mailbox saved', + 'ui.qwk.uplinks.deleted' => 'QWK mailbox deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK mailbox?', + 'ui.qwk.uplinks.polled' => 'QWK mailbox polled', + 'ui.qwk.uplinks.polling' => 'Polling QWK mailbox...', + 'ui.qwk.uplinks.result_download' => 'Downloaded packet', + 'ui.qwk.uplinks.result_imported' => 'Imported', + 'ui.qwk.uplinks.result_skipped' => 'Skipped', + 'ui.qwk.uplinks.result_rep_created' => 'Built REP packet', + 'ui.qwk.uplinks.result_uploaded' => 'Uploaded REP', + 'ui.qwk.uplinks.result_dry_run' => 'Skipped (dry run)', + 'ui.qwk.echoarea.subscriptions_title' => 'Connessioni di trasporto', + 'ui.qwk.echoarea.subscriptions_help' => 'Collega quest\'area echo a endpoint di trasporto esterni come mailbox QWK.', + 'ui.qwk.echoarea.relay_title' => 'Relay dei messaggi importati', + 'ui.qwk.echoarea.relay_help' => 'Controlla se i messaggi importati tramite trasporto vengono inoltrati agli altri trasporti collegati per quest\'area.', + 'ui.qwk.echoarea.relay_mode_none' => 'Nessun relay', + 'ui.qwk.echoarea.relay_mode_none_short' => 'Off', + 'ui.qwk.echoarea.relay_mode_auto' => 'Inoltra automaticamente a tutti gli altri trasporti collegati', + 'ui.qwk.echoarea.relay_mode_auto_short' => 'Auto', + 'ui.qwk.echoarea.relay_mode_manual' => 'Regole manuali di relay', + 'ui.qwk.echoarea.relay_rules_title' => 'Regole manuali di relay', + 'ui.qwk.echoarea.relay_rules_help' => 'Scegli quali messaggi importati tramite trasporto possono essere inoltrati agli altri trasporti collegati.', + 'ui.qwk.echoarea.relay_rules_unavailable' => 'Collega piu di un trasporto per usare le regole manuali di relay.', + 'ui.qwk.echoarea.none_subscriptions' => 'Nessuna connessione di trasporto ancora', + 'ui.qwk.echoarea.none_gates' => 'Nessun gate configurato', + 'ui.qwk.echoarea.local_only_notice' => 'Il relay di trasporto esterno e disabilitato finche quest\'area e marcata Local Only.', + 'ui.qwk.echoarea.summary_subscriptions' => '{count} collegate', + 'ui.qwk.echoarea.summary_subscriptions_none' => 'Nessuna', + 'ui.qwk.echoarea.summary_gates' => '{count} configurate', + 'ui.qwk.echoarea.summary_gates_none' => 'Nessuna', + 'ui.qwk.echoarea.summary_rules' => '{count} regole', + 'ui.qwk.echoarea.summary_rules_none' => 'Manuale', + 'ui.qwk.echoarea.transport_ftn' => 'FTN', + 'ui.qwk.echoarea.transport_qwk' => 'QWK', + 'ui.qwk.echoarea.gates_title' => 'Gates', + 'ui.qwk.echoarea.gates_help' => 'Mirror this area into other local echo areas.', + 'ui.qwk.echoarea.none_configured' => 'None configured', + 'ui.qwk.echoarea.uplink_label' => 'Connessione', + 'ui.qwk.echoarea.select_uplink' => 'Seleziona connessione', + 'ui.qwk.echoarea.conference_tag' => 'Tag remoto', + 'ui.qwk.echoarea.conference_number' => 'ID remoto', + 'ui.qwk.echoarea.gate_target' => 'Target Area', + 'ui.qwk.echoarea.select_gate_target' => 'Select target area', + 'ui.qwk.echoarea.bidirectional' => 'Bidirectional', + 'ui.qwk.echoarea_config_saved' => 'QWK subscriptions and gates saved', ]; diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 67274628e..f34d79674 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -139,6 +139,10 @@ 'errors.echoareas.not_found_or_unchanged' => 'Area echo non trovata o nessuna modifica effettuata', 'errors.echoareas.update_failed' => 'Impossibile aggiornare l’area echo', 'errors.echoareas.delete_blocked_has_messages' => 'Impossibile eliminare un’area echo con messaggi esistenti', + 'errors.echoareas.delete_action_required' => 'Scegli cosa fare con i messaggi rimanenti prima di eliminare quest\'area echo', + 'errors.echoareas.delete_invalid_action' => 'Azione di eliminazione non valida', + 'errors.echoareas.delete_move_target_required' => 'Seleziona un\'area echo di destinazione per spostare i messaggi rimanenti', + 'errors.echoareas.delete_move_target_invalid' => 'L\'area echo di destinazione selezionata non è valida', 'errors.echoareas.delete_failed' => 'Impossibile eliminare l’area echo', // File Areas @@ -720,4 +724,7 @@ '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.qwk.uplink_not_found' => 'QWK mailbox not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK mailbox configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK mailbox', ]; diff --git a/database/migrations/v20260523012838_qwk_mail_exchange_support.sql b/database/migrations/v20260523012838_qwk_mail_exchange_support.sql new file mode 100644 index 000000000..5b217a47b --- /dev/null +++ b/database/migrations/v20260523012838_qwk_mail_exchange_support.sql @@ -0,0 +1,88 @@ +-- Migration: 20260523012838 - qwk_mail_exchange_support +-- Created: 2026-05-23 01:28:38 UTC + +-- Add your SQL statements here +-- Each statement should end with semicolon followed by newline + +-- Example: +-- ALTER TABLE users ADD COLUMN new_field VARCHAR(100); + +-- CREATE INDEX idx_new_field ON users(new_field); +CREATE TABLE IF NOT EXISTS qwk_mailboxes ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + bbs_id VARCHAR(8) NOT NULL, + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL DEFAULT 21, + username VARCHAR(100) NOT NULL, + password TEXT NOT NULL, + ftp_remote_path VARCHAR(500) NOT NULL DEFAULT '/', + poll_schedule VARCHAR(100), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_polled_at TIMESTAMPTZ NULL, + last_error TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS echo_area_qwk_subscriptions ( + id SERIAL PRIMARY KEY, + echoarea_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, + mailbox_id INTEGER NOT NULL REFERENCES qwk_mailboxes(id) ON DELETE CASCADE, + conference_tag VARCHAR(50) NOT NULL, + conference_number INTEGER NOT NULL, + auto_created BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT echo_area_qwk_subscriptions_area_mailbox_key UNIQUE (echoarea_id, mailbox_id), + CONSTRAINT echo_area_qwk_subscriptions_mailbox_conf_key UNIQUE (mailbox_id, conference_number) +); + +CREATE TABLE IF NOT EXISTS qwk_outbound_messages ( + id SERIAL PRIMARY KEY, + echomail_id INTEGER NOT NULL REFERENCES echomail(id) ON DELETE CASCADE, + mailbox_id INTEGER NOT NULL REFERENCES qwk_mailboxes(id) ON DELETE CASCADE, + queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ NULL, + CONSTRAINT qwk_outbound_messages_unique UNIQUE (echomail_id, mailbox_id) +); + +CREATE TABLE IF NOT EXISTS echo_area_gates ( + id SERIAL PRIMARY KEY, + source_area_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, + target_area_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, + bidirectional BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT echo_area_gates_unique UNIQUE (source_area_id, target_area_id), + CONSTRAINT echo_area_gates_no_self CHECK (source_area_id <> target_area_id) +); + +ALTER TABLE echomail + ADD COLUMN IF NOT EXISTS qwk_mailbox_id INTEGER REFERENCES qwk_mailboxes(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS qwk_conference_number INTEGER, + ADD COLUMN IF NOT EXISTS qwk_msg_number INTEGER, + ADD COLUMN IF NOT EXISTS source_msgid VARCHAR(255); + +CREATE INDEX IF NOT EXISTS idx_qwk_subscriptions_area + ON echo_area_qwk_subscriptions (echoarea_id); + +CREATE INDEX IF NOT EXISTS idx_qwk_subscriptions_mailbox + ON echo_area_qwk_subscriptions (mailbox_id); + +CREATE INDEX IF NOT EXISTS idx_qwk_outbound_pending + ON qwk_outbound_messages (mailbox_id, sent_at); + +CREATE INDEX IF NOT EXISTS idx_echo_area_gates_source + ON echo_area_gates (source_area_id); + +CREATE INDEX IF NOT EXISTS idx_echo_area_gates_target + ON echo_area_gates (target_area_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_echomail_qwk_dedupe + ON echomail (qwk_mailbox_id, qwk_conference_number, qwk_msg_number) + WHERE qwk_mailbox_id IS NOT NULL + AND qwk_conference_number IS NOT NULL + AND qwk_msg_number IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_echomail_source_msgid_area + ON echomail (source_msgid, echoarea_id) + WHERE source_msgid IS NOT NULL; diff --git a/database/migrations/v20260523041929_add_dovenet_qwk_network.sql b/database/migrations/v20260523041929_add_dovenet_qwk_network.sql new file mode 100644 index 000000000..0939bd30e --- /dev/null +++ b/database/migrations/v20260523041929_add_dovenet_qwk_network.sql @@ -0,0 +1,33 @@ +-- Migration: 20260523041929 - add dovenet qwk network +-- Created: 2026-05-23 04:19:29 UTC + +INSERT INTO networks (domain, name, description, website, network_type, allow_markup, allow_media, default_charset, posting_name_policy, is_builtin) +VALUES ( + 'dovenetqwk', + 'Dovenet QWK', + 'Main Dovenet QWK Network', + 'https://wiki.synchro.net/network:dove-net', + 2, + FALSE, + FALSE, + 'CP437', + 'real_name', + TRUE +) +ON CONFLICT (domain) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + website = EXCLUDED.website, + network_type = EXCLUDED.network_type, + allow_markup = EXCLUDED.allow_markup, + allow_media = EXCLUDED.allow_media, + default_charset = EXCLUDED.default_charset, + posting_name_policy = EXCLUDED.posting_name_policy, + is_builtin = EXCLUDED.is_builtin, + updated_at = NOW() AT TIME ZONE 'UTC'; + +UPDATE networks +SET name = 'Dovenet FTN (clrghouz)', + description = 'DoveNet FTN network via the clrghouz FTN gateway.', + updated_at = NOW() AT TIME ZONE 'UTC' +WHERE domain = 'dovenet'; diff --git a/database/migrations/v20260523042737_add_qwk_mailbox_passive_mode.sql b/database/migrations/v20260523042737_add_qwk_mailbox_passive_mode.sql new file mode 100644 index 000000000..6841dda2a --- /dev/null +++ b/database/migrations/v20260523042737_add_qwk_mailbox_passive_mode.sql @@ -0,0 +1,5 @@ +-- Migration: 20260523042737 - add qwk mailbox passive mode +-- Created: 2026-05-23 04:27:37 UTC + +ALTER TABLE qwk_mailboxes + ADD COLUMN IF NOT EXISTS passive_mode BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/database/migrations/v20260523080229_add_qwk_network.sql b/database/migrations/v20260523080229_add_qwk_network.sql new file mode 100644 index 000000000..c7574a233 --- /dev/null +++ b/database/migrations/v20260523080229_add_qwk_network.sql @@ -0,0 +1,26 @@ +-- Migration: 20260523080229 - add_qwk_network +-- Created: 2026-05-23 08:02:29 UTC + +INSERT INTO networks (domain, name, description, website, network_type, allow_markup, allow_media, default_charset, posting_name_policy, is_builtin) +VALUES ( + 'qwk', + 'QWK', + 'Miscellaneous QWK Areas', + NULL, + 2, + FALSE, + FALSE, + 'CP437', + 'real_name', + TRUE +) +ON CONFLICT (domain) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + network_type = EXCLUDED.network_type, + allow_markup = EXCLUDED.allow_markup, + allow_media = EXCLUDED.allow_media, + default_charset = EXCLUDED.default_charset, + posting_name_policy = EXCLUDED.posting_name_policy, + is_builtin = EXCLUDED.is_builtin, + updated_at = NOW() AT TIME ZONE 'UTC'; diff --git a/database/migrations/v20260526054029_add_bbs_account_flag_to_users.sql b/database/migrations/v20260526054029_add_bbs_account_flag_to_users.sql new file mode 100644 index 000000000..9c53998ab --- /dev/null +++ b/database/migrations/v20260526054029_add_bbs_account_flag_to_users.sql @@ -0,0 +1,12 @@ +-- Migration: 20260526054029 - add bbs account flag to users +-- Created: 2026-05-26 05:40:29 UTC + +-- Add your SQL statements here +-- Each statement should end with semicolon followed by newline + +-- Example: +-- ALTER TABLE users ADD COLUMN new_field VARCHAR(100); + +-- CREATE INDEX idx_new_field ON users(new_field); +ALTER TABLE users +ADD COLUMN IF NOT EXISTS is_bbs_account BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/database/migrations/v20260527025049_add_echoarea_transport_relay_policy.sql b/database/migrations/v20260527025049_add_echoarea_transport_relay_policy.sql new file mode 100644 index 000000000..e10f4661b --- /dev/null +++ b/database/migrations/v20260527025049_add_echoarea_transport_relay_policy.sql @@ -0,0 +1,19 @@ +-- Migration: 20260527025049 - add_echoarea_transport_relay_policy +-- Created: 2026-05-27 02:50:49 UTC + +ALTER TABLE echoareas + ADD COLUMN relay_mode VARCHAR(20) NOT NULL DEFAULT 'auto', + ADD CONSTRAINT chk_echoareas_relay_mode + CHECK (relay_mode IN ('none', 'auto', 'manual')); + +CREATE TABLE echo_area_relay_rules ( + id SERIAL PRIMARY KEY, + echoarea_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, + origin_type VARCHAR(50) NOT NULL, + target_type VARCHAR(50) NOT NULL, + is_allowed BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_echo_area_relay_rules_types + CHECK (origin_type <> '' AND target_type <> ''), + CONSTRAINT uq_echo_area_relay_rules UNIQUE (echoarea_id, origin_type, target_type) +); diff --git a/docs/API.md b/docs/API.md index ffb9b60a3..79fc203ff 100644 --- a/docs/API.md +++ b/docs/API.md @@ -68,7 +68,7 @@ Content-Type: application/json - [Dashboard](#dashboard) (2) - [Debug](#debug) (1) - [Docs](#docs) (1) - - [Echoareas](#echoareas) (7) + - [Echoareas](#echoareas) (9) - [Fileareas](#fileareas) (10) - [Files](#files) (26) - [Freq Log](#freq-log) (1) @@ -83,7 +83,7 @@ Content-Type: application/json - [Notify](#notify) (3) - [Pending Users](#pending-users) (4) - [Polls](#polls) (3) - - [Qwk](#qwk) (7) + - [Qwk](#qwk) (13) - [Referrals](#referrals) (2) - [Register](#register) (1) - [Shoutbox](#shoutbox) (2) @@ -1819,6 +1819,8 @@ Rendered help documentation in HTML format. | `DELETE` | [`/api/echoareas/{id}`](#delete-apiechoareasid) | Yes | Delete an echo area. | | `GET` | [`/api/echoareas/stats`](#get-apiechoareasstats) | Yes | Get echo area statistics. | | `GET` | [`/api/echoareas/simple-list`](#get-apiechoareassimple-list) | Yes | Lightweight list of all echo areas for admin comboboxes. | +| `GET` | [`/api/echoareas/{id}/qwk-config`](#get-apiechoareasidqwk-config) | Yes | Load QWK subscription and gate settings for an echo area. | +| `PUT` | [`/api/echoareas/{id}/qwk-config`](#put-apiechoareasidqwk-config) | Yes | Replace QWK subscription and gate settings for an echo area. | #### `GET /api/echoareas` @@ -1998,7 +2000,7 @@ Updated echo area **Requires authentication** -Deletes an echo area only if it contains no messages. Admin-only. Returns error if area has messages; deactivation is recommended instead. Cascades delete to subscriptions and related data. +Deletes an echo area. Admin-only. If the area still contains messages, the request must specify whether to delete those messages or move them into another echo area before the area itself is removed. Cascades delete to subscriptions and related data. **Path Parameters** @@ -2006,6 +2008,15 @@ Deletes an echo area only if it contains no messages. Admin-only. Returns error |------|------|-------------| | `id` | integer | Echo area ID | +**Request Body** _(JSON, optional)_ + +Required when the echo area still contains messages. + +| Field | Type | Description | +|-------|------|-------------| +| `message_action` | string | `delete_messages` or `move_messages` | +| `target_echoarea_id` | integer or null | Required when `message_action` is `move_messages` | + **Response** _(JSON)_ Deletion confirmation @@ -2019,7 +2030,7 @@ Deletion confirmation | Status | Description | |--------|-------------| -| 400 | Cannot delete area with messages, or area not found | +| 400 | Missing/invalid message action, invalid move target, or area not found | | 403 | Admin privileges required | --- @@ -2064,6 +2075,81 @@ Array of echo areas --- +#### `GET /api/echoareas/{id}/qwk-config` + +**Requires authentication** + +Admin-only endpoint that returns the QWK-network configuration for one echo +area: mapped remote conferences, imported-message relay policy, local gate +rules, all configured QWK mailboxes, and other available local areas that can +be chosen as gate targets. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | Echo area ID | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `subscriptions` | array | QWK conference mappings for this echo area | +| `relay_mode` | string | Imported-message relay mode: `none`, `auto`, or `manual` | +| `relay_rules` | array | Manual relay rules as `{origin_type, target_type, is_allowed}` objects | +| `available_transports` | array | Transport types currently relevant to this area | +| `gates` | array | Gate definitions involving this echo area | +| `mailboxes` | array | All configured QWK mailboxes | +| `available_areas` | array | Other local echo areas available as gate targets | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 404 | Echo area not found | + +--- + +#### `PUT /api/echoareas/{id}/qwk-config` + +**Requires authentication** + +Admin-only endpoint that atomically replaces the QWK conference subscriptions, +imported-message relay policy, and local gate rules for an echo area. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | Echo area ID | + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subscriptions` | array | No | Array of `{mailbox_id, conference_tag, conference_number}` objects | +| `relay_mode` | string | No | Imported-message relay mode: `none`, `auto`, or `manual` | +| `relay_rules` | array | No | Array of `{origin_type, target_type, is_allowed}` objects | +| `gates` | array | No | Array of `{target_area_id, bidirectional}` objects | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the configuration was saved | +| `message_code` | string | Localization key for UI success messaging | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 404 | Echo area not found | +| 400 | Invalid configuration payload or save failure | + +--- + ### Fileareas | Method | Path | Auth | Summary | @@ -5879,6 +5965,12 @@ JSON object with created poll ID and details | `GET` | [`/api/qwk/area-selections`](#get-apiqwkarea-selections) | Yes | Retrieve user's QWK area selections and available subscriptions. | | `POST` | [`/api/qwk/area-selections`](#post-apiqwkarea-selections) | Yes | Save user's QWK area selection for packet generation. | | `GET` | [`/api/qwk/area-search`](#get-apiqwkarea-search) | Yes | Search echo areas by tag or description for QWK selection. | +| `GET` | [`/api/qwk-mailboxes`](#get-apiqwk-mailboxes) | Yes | List configured QWK mailboxes for the admin UI. | +| `GET` | [`/api/qwk-mailboxes/{id}`](#get-apiqwk-mailboxesid) | Yes | Load one QWK mailbox including its decrypted password for editing. | +| `POST` | [`/api/qwk-mailboxes`](#post-apiqwk-mailboxes) | Yes | Create a QWK mailbox configuration. | +| `PUT` | [`/api/qwk-mailboxes/{id}`](#put-apiqwk-mailboxesid) | Yes | Update a QWK mailbox configuration. | +| `DELETE` | [`/api/qwk-mailboxes/{id}`](#delete-apiqwk-mailboxesid) | Yes | Delete a QWK mailbox configuration. | +| `POST` | [`/api/qwk-mailboxes/{id}/poll`](#post-apiqwk-mailboxesidpoll) | Yes | Poll one QWK mailbox immediately. | #### `POST /api/qwk/upload` @@ -6081,6 +6173,186 @@ Search results --- +#### `GET /api/qwk-mailboxes` + +**Requires authentication** + +Admin-only endpoint that lists all configured QWK mailboxes for the management +UI. Passwords are not returned by this list endpoint. + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `mailboxes` | array | QWK mailbox records with status metadata | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | + +--- + +#### `GET /api/qwk-mailboxes/{id}` + +**Requires authentication** + +Admin-only endpoint that returns one QWK mailbox for editing, including the +decrypted password in `password_plain`. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK mailbox ID | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `mailbox` | object | Full QWK mailbox record | +| `mailbox.password_plain` | string | Decrypted password for edit-form reuse | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 404 | QWK mailbox not found | + +--- + +#### `POST /api/qwk-mailboxes` + +**Requires authentication** + +Admin-only endpoint that creates a new QWK mailbox definition. + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Friendly mailbox name | +| `bbs_id` | string | Yes | Remote 8-character QWK BBS ID | +| `host` | string | Yes | FTP hostname | +| `port` | integer | No | FTP port (default `21`) | +| `username` | string | Yes | FTP username | +| `password` | string | Yes | FTP password | +| `ftp_remote_path` | string | No | Remote directory containing packets | +| `poll_schedule` | string | No | Optional schedule hint | +| `enabled` | boolean | No | Whether the uplink should be polled | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `id` | integer | New QWK mailbox ID | +| `message_code` | string | Localization key for UI success messaging | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 400 | Invalid mailbox payload or save failure | + +--- + +#### `PUT /api/qwk-mailboxes/{id}` + +**Requires authentication** + +Admin-only endpoint that updates an existing QWK mailbox. If `password` is sent +blank, the previous password is retained. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK mailbox ID | + +**Request Body** _(JSON)_ + +Same shape as `POST /api/qwk-mailboxes`. + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `message_code` | string | Localization key for UI success messaging | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 400 | Invalid mailbox payload or save failure | + +--- + +#### `DELETE /api/qwk-mailboxes/{id}` + +**Requires authentication** + +Admin-only endpoint that deletes a configured QWK mailbox. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK mailbox ID | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on success | +| `message_code` | string | Localization key for UI success messaging | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 404 | QWK mailbox not found | + +--- + +#### `POST /api/qwk-mailboxes/{id}/poll` + +**Requires authentication** + +Admin-only endpoint that immediately polls one QWK mailbox, imports any inbound +packet, builds an outbound `.REP` if needed, and attempts upload. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK mailbox ID | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the poll cycle completed successfully | +| `imported` | integer | Number of inbound messages imported | +| `skipped` | integer | Number of inbound messages skipped | +| `uploaded` | boolean | Whether a `.REP` packet was uploaded | +| `message_code` | string | Localization key for UI success messaging | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 400 | Poll failed | + +--- + ### Referrals | Method | Path | Auth | Summary | @@ -7608,7 +7880,7 @@ 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 object with id, username, real_name, email, credit_balance, is_active, is_admin, is_system, is_bbs_account, echomail_moderation_forced, created_at, last_login | **Error Responses** @@ -7684,6 +7956,7 @@ User update fields | `is_active` | integer | No | 1 for active, 0 for inactive (default: 1) | | `is_admin` | integer | No | 1 to grant admin, 0 to revoke (default: 0) | | `is_system` | integer | No | 1 for system account, 0 otherwise (default: 0) | +| `is_bbs_account` | integer | No | 1 for a BBS user account that may preserve QWK REP `From` names, 0 otherwise (default: 0) | | `echomail_moderation_forced` | integer | No | 1 to force moderation, 0 to allow (default: 0) | | `password` | string | No | New password (if provided, updates user's password) | @@ -7763,6 +8036,7 @@ New user details | `is_active` | integer | No | 1 for active, 0 for inactive (default: 1) | | `is_admin` | integer | No | 1 to create as admin, 0 otherwise (default: 0) | | `is_system` | integer | No | 1 for system account, 0 otherwise (default: 0) | +| `is_bbs_account` | integer | No | 1 for a BBS user account that may preserve QWK REP `From` names, 0 otherwise (default: 0) | **Response** _(JSON)_ diff --git a/docs/AdminDaemon.md b/docs/AdminDaemon.md index 5089052c2..99f263b65 100644 --- a/docs/AdminDaemon.md +++ b/docs/AdminDaemon.md @@ -91,6 +91,10 @@ php scripts/admin_daemon.php \ | `--socket-perms=MODE` | Unix socket permissions (octal) | | `--daemon` | Fork into background (requires `pcntl`) | +The admin daemon's UDP log fallback allowlist includes the core daemon and +transport logs such as `binkp_poll.log`, `qwk_poll.log`, `binkp_server.log`, +`binkp_scheduler.log`, `admin_daemon.log`, and `packets.log`. + --- ## Wire Protocol diff --git a/docs/CLI.md b/docs/CLI.md index 1b7efe891..4b06377df 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -22,6 +22,7 @@ BinktermPHP includes a full suite of CLI tools for managing your system from the - [Geocoding](#geocoding) - [Database Backup](#database-backup) - [Crashmail Poll](#crashmail-poll) +- [QWK Mail Exchange Poll](#qwk-mail-exchange-poll) - [FREQ File Pickup](#freq-file-pickup) - [Outbound FREQ (File Request)](#outbound-freq-file-request) - [Echomail Robots](#echomail-robots) @@ -630,6 +631,37 @@ Options: - `--verbose` — Show detailed output - `--dry-run` — Check queue without attempting delivery +## QWK Mail Exchange Poll + +Polls configured QWK mailboxes, imports inbound `.QWK` packets into mapped +local echo areas, builds outbound `.REP` packets from queued local posts, and +uploads replies back to the remote BBS. + +This script can be run manually, and the same poller is also invoked by +`scripts/binkp_scheduler.php` for any enabled QWK mailbox that has a +`poll_schedule`. + +```bash +# Poll all enabled QWK mailboxes +php scripts/qwk_poll.php --all + +# Poll one configured QWK mailbox by numeric ID +php scripts/qwk_poll.php 3 + +# Quiet mode for cron jobs +php scripts/qwk_poll.php --all --quiet +``` + +Options: +- `--all` — Poll every enabled QWK mailbox +- `--quiet` — Print only success/failure status +- `--help` — Show usage +- `--dry-run` — Download/import/build packets but skip REP upload +- `--json` — Emit machine-readable JSON output +- `--log-level=LVL` — `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` +- `--log-file=FILE` — Log file path (default: `data/logs/qwk_poll.log`) +- `--no-console` — Suppress console logging while still writing the log file + ## FREQ File Pickup Use this script when you have sent a FREQ request to a remote node that cannot diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 5a704f453..71ca96651 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -37,6 +37,8 @@ The central table. Stores every public FTN message received or posted. | `kludge_lines` | Raw kludge lines from the original packet (includes `CHRS`, `TZUTC`, etc.) | | `message_charset` | Normalized charset for encoding/decoding (e.g. `CP437`, `UTF-8`) | | `art_format` | Set when the message is ANSI, Sixel, RIPscrip, etc. | +| `qwk_mailbox_id` / `qwk_conference_number` / `qwk_msg_number` | Present on inbound QWK-network messages for deduplication and reply mapping | +| `source_msgid` | Original upstream or gated message identifier used to prevent duplicate mirrored copies | **Key rule**: prefer `date_received` for display ordering; show `date_written` only as supplementary information (it can be wrong or in the future if the sender's clock is off). Future-dated `date_written` values are suppressed from message list queries until they are no longer in the future. @@ -55,6 +57,7 @@ One row per echo area (conference/forum). | `domain` | Network domain (e.g. `fidonet`, `lovlynet`) — allows the same tag in multiple networks | | `description` | Human-readable area name | | `is_local` | When true, messages are never forwarded to uplinks | +| `relay_mode` | Imported-message relay policy: `none`, `auto`, or `manual` | | `is_active` | Inactive areas are hidden from all queries and API responses | | `is_sysop_only` | When true, only admin users can see the area | | `moderator` | FTN address of the area moderator, if any | @@ -125,6 +128,78 @@ Imported FTN nodelist data. `nodelist` holds one row per node (zone, net, node, One row per completed binkp session (inbound or outbound). Records duration, bytes exchanged, files transferred, and outcome. Used by the admin analytics dashboard. +### `networks` + +Logical message networks and their posting capabilities. A QWK-capable network +such as DoveNet can be represented here with `network_type = 2`, but transport +credentials live separately in `qwk_mailboxes`. + +### `qwk_mailboxes` + +Remote QWK transport accounts. One mailbox can carry conferences from multiple +logical networks. + +| Column | Notes | +|--------|-------| +| `id` | Primary key | +| `name` | Friendly admin label | +| `bbs_id` | Remote QWK packet ID | +| `host` / `port` | FTP endpoint used for packet exchange | +| `username` / `password` | Remote login credentials; password is stored encrypted | +| `ftp_remote_path` | Remote directory containing `.QWK` and `.REP` packets | +| `poll_schedule` | Optional scheduler hint / cron-like expression | +| `enabled` | Whether the mailbox should be polled | +| `last_polled_at` / `last_error` | Status from the last poll attempt | + +### `echo_area_qwk_subscriptions` + +Maps a local echo area to a conference number on a specific QWK mailbox. + +| Column | Notes | +|--------|-------| +| `echoarea_id` | FK → `echoareas.id` | +| `mailbox_id` | FK → `qwk_mailboxes.id` | +| `conference_tag` | Remote or admin label for the conference | +| `conference_number` | Remote QWK conference number used in packets | +| `auto_created` | Whether the mapping was auto-created from inbound traffic | + +These rows drive both directions: inbound `.QWK` import routing and outbound +`.REP` queue generation. + +### `echo_area_relay_rules` + +Per-area manual relay rules for transport-imported messages. + +| Column | Notes | +|--------|-------| +| `echoarea_id` | FK → `echoareas.id` | +| `origin_type` | Source transport type such as `ftn` or `qwk` | +| `target_type` | Destination transport type | +| `is_allowed` | Whether this origin → target relay is permitted in manual mode | + +### `qwk_outbound_messages` + +Queue table for local echomail messages that still need to be exported to one +or more QWK mailboxes. + +| Column | Notes | +|--------|-------| +| `echomail_id` | FK → `echomail.id` | +| `mailbox_id` | FK → `qwk_mailboxes.id` | +| `queued_at` | When the message was queued for export | +| `sent_at` | Set after a successful `.REP` upload | + +### `echo_area_gates` + +Defines local cross-area mirroring rules for echoarea message gating across +multiple networks and import paths. + +| Column | Notes | +|--------|-------| +| `source_area_id` | FK → `echoareas.id` | +| `target_area_id` | FK → `echoareas.id` | +| `bidirectional` | When true, the same row mirrors traffic both ways | + --- ## Real-Time Tables @@ -162,7 +237,8 @@ See [BinkStreamChannel.md](BinkStreamChannel.md) for the full architecture. | `fileareas` | File area definitions (tag, domain, description, path) | | `shared_files` | Files shared via the webshare system | | `freq_log` / `freq_outbound` | File request (FREQ) history and outbound queue | -| `qwk_conference_state` / `qwk_message_index` | QWK offline mail reader state | +| `qwk_conference_state` / `qwk_message_index` | Per-user QWK offline mail reader state | +| `qwk_mailboxes` / `echo_area_qwk_subscriptions` / `echo_area_relay_rules` / `qwk_outbound_messages` / `echo_area_gates` | QWK network exchange configuration, relay policy, queueing, and local gating | | `interests` / `interest_echoareas` / `user_interest_subscriptions` | Topic-based area groupings | | `ai_requests` | Per-request AI usage accounting | | `ai_bots` / `ai_bot_activities` | AI bot definitions and activity log | diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 63ceaceee..3f08f0ae9 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -130,7 +130,7 @@ For full data-flow diagrams including the FTN packet lifecycle, daemon IPC model - **Uplinks**: Remote FidoNet nodes that relay messages to/from the wider network - **Domains**: FTN addressing uses zone:net/node.point format (e.g., 21:1/999) -- **Local Echoareas**: Areas marked `is_local=true` are not transmitted to uplinks +- **Local Echoareas**: Areas marked `is_local=true` must never be propagated through any external transport layer. That means no FTN spooling, no inter-BBS QWK mailbox fanout, and no other network redistribution. The only allowed QWK behavior for a local area is the logged-in user's own offline-reader workflow on this same BBS (download in their packet, upload replies back into the same area). - **Multi-Network Support**: System can connect to multiple independent FTN networks #### Packet Processing diff --git a/docs/EchoAreas.md b/docs/EchoAreas.md index 78b795571..727437dd8 100644 --- a/docs/EchoAreas.md +++ b/docs/EchoAreas.md @@ -4,16 +4,43 @@ Echo areas are public message forums distributed across FidoNet-compatible (FTN) --- +## Table of Contents + +- [Concepts](#concepts) +- [Database Schema](#database-schema) +- [Admin Configuration](#admin-configuration) + - [Screen Overview](#screen-overview) + - [Creating an Area](#creating-an-area) + - [QWK Subscriptions](#qwk-subscriptions) + - [Gates](#gates) + - [Editing and Deleting](#editing-and-deleting) + - [Statistics](#statistics) + - [Bulk CSV Import](#bulk-csv-import) +- [Inbound Message Flow](#inbound-message-flow) +- [Outbound Message Flow](#outbound-message-flow) +- [Echomail Moderation](#echomail-moderation) +- [User Subscriptions](#user-subscriptions) +- [Multi-Network Support](#multi-network-support) +- [Gemini Access](#gemini-access) +- [Related Documentation](#related-documentation) + +--- + ## Concepts ### Tags and Domains -Every echo area is identified by a **tag** (e.g., `GENERAL`, `MICRONET.CHAT`) and a **domain** (e.g., `fidonet`, `lovlynet`). The combination of tag + domain is unique, so the same tag can exist independently in multiple networks. A local area has no domain (or an empty domain) and is never transmitted to any uplink. +Every echo area is identified by a **tag** (e.g., `GENERAL`, `MIN_CHAT`) and a **domain** (e.g., `fidonet`, `lovlynet`). The combination of tag + domain is unique, so the same tag can exist independently in multiple networks. A local area has no domain (or an empty domain) and is never transmitted to any uplink. ### Local vs. Networked Areas -- **Networked areas** — messages are exchanged with uplinks. Inbound messages arrive in packets; outbound messages are bundled into packets at the next poll. -- **Local areas** (`is_local = true`) — messages are stored locally only and never sent to uplinks. Useful for internal discussion, testing, or community areas that are not part of any FTN network. +- **Networked areas** — messages may be distributed externally. Depending on the area's configuration, this can include FTN uplinks, QWK mailbox fanout, or both. +- **Local areas** (`is_local = true`) — messages are stored locally only and are never propagated to any external network transport. That means: + - no FTN uplink spooling + - no inter-BBS QWK mailbox distribution + - no other external redistribution path + +Local areas can still appear in the logged-in user's own offline QWK reader packet on this BBS, and replies uploaded by that same user can still be imported back into the local area. That user-facing offline-reader workflow is not considered external network propagation. ### Auto-Creation @@ -34,7 +61,7 @@ Echo areas are stored in the `echoareas` table. Key columns: | `moderator` | VARCHAR(100) | Moderator name. Optional. | | `color` | VARCHAR(7) | Hex color for the web UI (default `#28a745`). | | `is_active` | BOOLEAN | Whether the area is visible and accepting messages. | -| `is_local` | BOOLEAN | If true, messages are never sent to uplinks. | +| `is_local` | BOOLEAN | If true, the area is local-only: no FTN spooling, no inter-BBS QWK fanout, and no other external propagation. | | `is_sysop_only` | BOOLEAN | If true, only admin users can access the area. | | `gemini_public` | BOOLEAN | If true, the area is readable via the Gemini protocol without login. | | `posting_name_policy` | VARCHAR(20) | Per-area name policy: `real_name`, `username`, or NULL to inherit. | @@ -49,6 +76,18 @@ The `echomail` table holds individual messages and references `echoareas.id`. Th Echo areas are managed at **Admin → Echo Areas** (`/echoareas`). +### Screen Overview + +The Echo Areas screen includes: + +- a searchable area list with active/all/inactive filters +- domain filter buttons so you can narrow the list to one network or local areas +- summary statistics for active areas and message volume +- an **Import** button for bulk CSV or `.NA` imports +- a **Manage QWK Mailboxes** button for configuring QWK network peers used by area subscriptions + +Default subscriptions are not managed on this screen. Those are configured separately at **Admin → Subscriptions**, where you choose which areas new users are auto-subscribed to. + ### Creating an Area Fill in the form fields: @@ -63,14 +102,80 @@ Fill in the form fields: - *(inherit)* — Use the policy configured for the network (default behavior). - `real_name` — Use the user's real name field. - `username` — Use the user's login username. +- **Art Format Hint** — Optional hint for terminal art rendering in this area. + - *(inherit)* — Use the system or network default behavior. + - `ansi` — Prefer IBM/PC ANSI assumptions. + - `amiga_ansi` — Prefer Amiga-style ANSI assumptions. - **Active** — Uncheck to disable the area without deleting it. -- **Local Only** — Check to prevent messages from being sent to uplinks. +- **Local Only** — Check to make the area local-only. Local-only areas are never propagated to FTN uplinks, QWK mailbox peers, or any other external transport. They can still be included in a user's own offline QWK packet on this BBS. - **Sysop Access Only** — Check to restrict the area to admin users. - **Public Gemini Access** — Check to allow read-only access from Gemini protocol clients. +- **Allow Media** — Per-area override for inline media handling in messages: + - *(inherit)* — Use the network-level media policy. + - `allow` — Explicitly allow media in this area. + - `deny` — Explicitly block media in this area. + +If the area belongs to LovlyNet and the local description or recommended settings differ from the network metadata, the edit dialog can also show a **Sync** button to pull the LovlyNet description back into the local record. + +### QWK Subscriptions + +The lower part of the edit dialog includes a **QWK Subscriptions** panel. This is where you map the area to one or more conferences on configured QWK mailboxes. + +Each subscription row contains: + +- **Mailbox** — which configured QWK peer to use +- **Conference Tag** — the remote conference name or tag +- **Conference #** — the numeric conference mapping used in packets + +This is area-level transport mapping. QWK mailbox hostnames, credentials, schedules, and FTP settings are managed from the separate **Manage QWK Mailboxes** dialog. For inter-BBS QWK transport details, see [docs/QWKNetworking.md](QWKNetworking.md). + +### Imported Message Relay + +The same edit dialog also includes an **Imported Message Relay** setting. This +controls only what happens after a message is imported from an external +transport such as FTN or inter-BBS QWK. + +The available modes are: + +- **No relay** — imported messages are stored in this area only +- **Auto relay** — imported messages are forwarded to all other connected transports for this area, skipping the same origin transport +- **Manual relay rules** — imported messages are forwarded only for explicitly allowed origin → target transport pairs + +This relay policy does not change how local users posting directly into the +area behave. Local posts still use the area's normal outbound transport +subscriptions. + +### Gates + +The same edit dialog also includes a **Gates** panel. Gates are not QWK-specific even though they currently share the same configuration section. + +A gate links two distinct echo areas so that newly imported or newly posted messages in one area are copied into the other area. This is used when the two sides must remain separate area records, such as: + +- the same topic carried under different tags on two networks +- a local mirror area that relays into a networked area +- a QWK-backed area mirrored into an FTN-backed area + +Each gate row contains: + +- **Target Area** — the other local echo area record to mirror into +- **Bidirectional** — when enabled, traffic flows both ways; when disabled, it flows only from the current area to the target area + +Important gate behavior: + +- Gates apply to new messages only. They do not retroactively copy historical messages. +- The copied message is stored as a separate local `echomail` row in the target area. +- After the copy is created, the target area's own delivery rules apply. If the target area is networked, it may then spool to FTN, queue for QWK, or both. +- Loop protection uses the original/source message ID so a gated copy returning from another network is not imported again endlessly. +- Self-gates are not allowed. ### Editing and Deleting -Click the edit icon next to any area to modify its settings. Areas that contain messages **cannot be deleted** — deactivate them instead by unchecking **Active**. This preserves message history while hiding the area from users. +Click the edit icon next to any area to modify its settings. When deleting an area, the confirmation dialog now asks what to do with any remaining messages: + +- **Delete them** — removes the area and permanently deletes the messages in it. +- **Move them to another area** — reassigns the messages to a different echo area before deleting the original area. This is a local move only; it does not re-gate, re-spool, or republish those historical messages. + +If you want to keep the area and its current message history intact, uncheck **Active** instead. ### Statistics @@ -96,6 +201,10 @@ LOCALNEWS,Local News and Announcements, ## Inbound Message Flow +Inbound echomail can arrive through two external transports: FTN packets and inter-BBS QWK mailbox exchange. + +### FTN Inbound + 1. **Packet reception** — The binkp server writes received files to `data/inbound/`. Packets are named with `.pkt` (raw packet) or FTN day-of-week bundle extensions (`.su0`, `.mo1`, etc.). 2. **Processing** — `scripts/process_packets.php` (run by cron or triggered by the admin daemon) calls `BinkdProcessor::processInboundPackets()`. 3. **Packet parsing** — Each `.pkt` file is parsed according to FTS-0001. The packet password is validated against the configured uplink password, and packets from insecure sessions are rejected for echomail (only netmail is allowed from insecure sessions). @@ -105,17 +214,35 @@ LOCALNEWS,Local News and Announcements, 7. **Storage** — The message is stored in the `echomail` table. The `message_count` on the area is incremented. Kludge lines are split into top kludges and bottom kludges (SEEN-BY/PATH) per FTS-4009. 8. **Failure handling** — If processing fails, the original packet is moved to `data/undeliverable/` rather than deleted, so it can be reprocessed manually. +### Inter-BBS QWK Inbound + +1. **Mailbox poll** — `scripts/qwk_poll.php` polls an enabled QWK mailbox, usually on a schedule or via the admin daemon. +2. **Packet download** — If the remote BBS has a `.QWK` packet waiting, BinktermPHP downloads it from the configured mailbox. +3. **Conference mapping** — Each inbound QWK message is matched to a local echo area using the mailbox ID plus the configured remote conference number. +4. **Auto-creation when needed** — If a conference mapping does not yet exist, BinktermPHP can auto-create a placeholder area and subscription for that remote conference. +5. **Duplicate check** — QWK duplicates are detected using the mailbox ID, conference number, and remote message number. +6. **Storage and linkage** — The message is stored in `echomail` with QWK source metadata so replies can preserve QWK threading and so the same message is not echoed straight back to the mailbox it came from. +7. **Post-import fanout** — Once stored, the message can still be copied through gates or delivered to other configured external transports according to the area's imported-message relay mode. Inter-BBS QWK imports are never sent back to the same originating mailbox. + Character encoding is detected via the `CHRS` kludge and converted to UTF-8 for storage. --- ## Outbound Message Flow -1. A user posts a message through the web interface or terminal server. +1. A user posts a message through the web interface or terminal server, or a remote message is imported into an area from FTN or QWK. 2. The message is stored in the `echomail` table. 3. If echomail moderation is enabled and the user has not yet earned unmoderated posting rights, the message is stored with `moderation_status = 'pending'` and held for sysop review (see [Echomail Moderation](#echomail-moderation) below). Otherwise it proceeds immediately. -4. At the next binkp poll, `BinkdProcessor` selects pending outbound messages for areas where `is_local = false` and `is_active = true`, and bundles them into a packet destined for the configured uplink. -5. The posting name in the outbound packet is determined by the area's `posting_name_policy` (or the network's default policy if the area policy is unset). +4. If the message is approved for delivery, BinktermPHP evaluates the area's configured outbound paths: + - **FTN outbound** — when the area belongs to an FTN-routable network, the message is spooled into outbound FTN packet flow for that uplink path + - **QWK outbound** — when the area has QWK conference subscriptions, the message is queued for one or more mailbox-specific `.REP` packets + - **Gates** — when the area has gate rules, the message is copied into the target local area or areas, and each target area then evaluates its own FTN/QWK outbound rules +5. QWK outbound delivery is mailbox-aware. If a message originally arrived from mailbox `A`, it is not queued straight back to mailbox `A`; only other configured destinations are considered. +6. During the next QWK poll cycle, pending messages for each mailbox are assembled into a `.REP` packet and uploaded to that remote BBS. +7. Local-only areas (`is_local = true`) never enter any external propagation queue. They remain on this BBS only, aside from the logged-in user's own offline-reader packet workflow. +8. The posting name in any FTN outbound packet is determined by the area's `posting_name_policy` (or the network's default policy if the area policy is unset). + +For the mailbox-side details of QWK polling, downloads, uploads, and conference mapping, see [docs/QWKNetworking.md](QWKNetworking.md). --- @@ -123,7 +250,7 @@ Character encoding is detected via the `CHRS` kludge and converted to UTF-8 for BinktermPHP includes an optional hold-for-approval queue for echomail posted by users who have not yet established a posting history. Once a user accumulates enough approved posts they are promoted automatically and never moderated again. -Moderation applies only to **networked** areas (`is_local = false`). Posts to local areas are always stored immediately regardless of the user's moderation status. +Moderation applies only to **networked** areas (`is_local = false`). Posts to local areas are always stored immediately and never enter any external propagation queue regardless of the user's moderation status. ### Enabling Moderation @@ -185,7 +312,20 @@ Users subscribe to echo areas to receive them in their message feed. Subscriptio ## Multi-Network Support -BinktermPHP supports simultaneous membership in multiple FTN networks. Each uplink selects a configured network domain in the BinkP settings. When a packet arrives from an uplink, its domain is used to scope the echo area lookup, so `GENERAL@fidonet` and `GENERAL@lovlynet` are stored and managed as separate areas even though they share the same tag. +BinktermPHP supports simultaneous membership in multiple FTN networks at the +system level. Each uplink selects a configured network domain in the BinkP +settings. When a packet arrives from an uplink, its domain is used to scope the +echo area lookup, so `GENERAL@fidonet` and `GENERAL@lovlynet` are stored and +managed as separate areas even though they share the same tag. + +Important limitation: + +- One echo area can belong to one FTN network domain at a time. +- An echo area cannot belong to more than one FTN network simultaneously. +- If you need the same tag on multiple FTN networks, create separate + tag+domain areas such as `GENERAL@fidonet` and `GENERAL@lovlynet`. +- A local area is the non-networked case: it has no domain and belongs to no + FTN network. Domain names are managed in **Admin → Networks** and are available in the domain drop-down when creating areas. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 6fd44a6b6..49d01ef80 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -27,7 +27,7 @@ BinktermPHP is developed and tested on Debian-based Linux distributions, includi --- ## Requirements -- **PHP 8.2+** with extensions: PDO, PostgreSQL, Sockets, JSON, DOM, Zip, OpenSSL, GMP +- **PHP 8.2+** with extensions: PDO, PostgreSQL, Sockets, JSON, DOM, Zip, OpenSSL, GMP, FTP - **NodeJS** for DOS Doors support (optional) - **PostgreSQL** - Database server - **Web Server** - Caddy, Apache, Nginx, etc. @@ -55,6 +55,7 @@ sudo apt-get install libapache2-mod-php apache2 # Install required packages sudo apt-get install php-zip php-mcrypt php-iconv php-mbstring php-pdo php-xml php-pgsql php-dom php-gmp postgresql composer +# Note: php-ftp is compiled into PHP on Debian/Ubuntu by default; on other platforms a separate package may be required sudo apt-get install -y unzip p7zip-full # Optional: Sixel image rendering in telnet/SSH terminal reader diff --git a/docs/QWK.md b/docs/QWK.md index a507f08a6..857ce1d3f 100644 --- a/docs/QWK.md +++ b/docs/QWK.md @@ -7,6 +7,8 @@ application, then reconnect and upload the reply packet. BinktermPHP supports the standard QWK format and the QWKE (QWK Extended) variant which carries full FidoNet metadata. +For information on InterBBS QWK networking see [QWK Networking](QWKNetworking.md). + ## Table of Contents - [How It Works](#how-it-works) @@ -25,15 +27,17 @@ FidoNet metadata. - [Deduplication](#deduplication) - [Reply Threading](#reply-threading) - [API Endpoints](#api-endpoints) +- [Related Docs](#related-docs) - [Recommended Readers](#recommended-readers) --- ## How It Works -1. **Download** a QWK packet from `/api/qwk/download`. The packet is a ZIP - archive named `BBSID.QWK` containing all new messages since your last - download across your subscribed echo areas and personal mail. +1. **Download** a QWK packet from the **QWK Offline Mail** page in the user + interface. The packet is a ZIP archive named `BBSID.QWK` containing all new + messages since your last download across your subscribed echo areas and + personal mail. 2. **Open** the packet in a QWK-capable offline reader. Read messages, compose replies, and write new messages. @@ -41,8 +45,9 @@ FidoNet metadata. 3. **Export** the reply packet from your reader. This is a ZIP archive named `BBSID.REP` containing a `BBSID.MSG` file with your outgoing messages. -4. **Upload** the REP packet to `/api/qwk/upload`. BinktermPHP imports your - messages, posts echomail to the appropriate areas, and routes netmail. +4. **Upload** the REP packet from the **QWK Offline Mail** page in the user + interface. BinktermPHP imports your messages, posts echomail to the + appropriate areas, and routes netmail. The same workflow is also available through the optional FTP daemon: @@ -53,6 +58,9 @@ You must download a QWK packet at least once before uploading a REP packet. The download establishes the conference map that BinktermPHP uses to route your replies back to the correct echo areas. +For inter-BBS QWK mailbox transport, conference mapping, gating, and `is_local` +delivery rules, see [QWK Networking](QWKNetworking.md). + --- ## Packet Formats @@ -111,6 +119,11 @@ number (stored persistently on the echo area record). These numbers are consistent across all users and across downloads — subscribing or unsubscribing from other areas does not change the conference numbers you already know. +Local-only areas may still be included in a user's personal QWK packet when the +user is subscribed to them. That user-facing offline-reader workflow is local +to this BBS. For external QWK mailbox transport rules, see +[QWK Networking](QWKNetworking.md). + Conference names in `CONTROL.DAT` are truncated to 13 characters. The format is `AREANAME` or `AREANAME@DOMAIN` when a network domain is present. @@ -183,6 +196,12 @@ REP upload parsing is shared across the web UI, the HTTP API, and FTP. There is no separate "QWKE upload mode" switch. BinktermPHP inspects the uploaded packet's contents and imports QWKE extended headers when they are present. +For normal user accounts, imported replies are posted using that local +account's configured posting identity. If the account has the admin-only +**Is BBS Account** flag enabled, REP import instead preserves the packet's +`From` name so a remote BBS can use one BBS user account while still passing +through the original remote caller's name. + ### Validation BinktermPHP validates the REP packet before importing any messages: @@ -241,6 +260,14 @@ If the optional FTP daemon is enabled, the equivalent FTP paths are: --- +## Related Docs + +- [QWK Networking](QWKNetworking.md) — Inter-BBS QWK mailbox transport, conference mapping, gating, and local-only delivery rules +- [Echo Areas](EchoAreas.md) — Area subscription and `is_local` behavior +- [API Reference](API.md) — QWK offline-mail and mailbox endpoints + +--- + ## Recommended Readers | Reader | Platform | QWK | QWKE | diff --git a/docs/QWKNetworking.md b/docs/QWKNetworking.md new file mode 100644 index 000000000..607672075 --- /dev/null +++ b/docs/QWKNetworking.md @@ -0,0 +1,108 @@ +# QWK Networking + +QWK networking in BinktermPHP is the inter-BBS transport mode where this +system exchanges packets with another BBS mailbox. This is separate from a +user's own offline-reader workflow on the same BBS. + +For the user-facing offline-reader download/upload workflow on this BBS, see +[QWK Offline Mail](QWK.md). + +## Table of Contents + +- [Overview](#overview) +- [Configuration](#configuration) +- [Polling and Packet Flow](#polling-and-packet-flow) +- [Behavior](#behavior) +- [Local-Only Areas](#local-only-areas) +- [Related Docs](#related-docs) + +--- + +## Overview + +BinktermPHP can act as a QWK client for another BBS. In this mode the local +system polls a remote QWK mailbox, downloads that system's `.QWK` packet, +imports mapped conferences into local echo areas, exports queued local posts as +a `.REP`, and uploads the reply packet back to the remote host. + +Important distinction: + +- A user's own offline QWK reader packet on this BBS is a local access method, not external network propagation. +- Inter-BBS QWK mailbox exchange is an external transport. + +--- + +## Configuration + +This is configured from the admin web interface: + +1. Open **Admin → Echo Areas**. +2. Use **QWK Mailboxes** to define the remote BBS ID, FTP host, credentials, + remote path, and poll schedule. +3. Edit a local echo area and add one or more **QWK Subscriptions** mapping the + local area to remote conference numbers on that mailbox. +4. Choose the area's **Imported Message Relay** mode to decide whether inbound + transport traffic should stay local, auto-relay to other connected + transports, or follow manual origin → target rules. + +If the BinkP scheduler daemon is running, enabled QWK mailboxes with a +`poll_schedule` are polled automatically by that scheduler. Mailboxes with a +blank schedule are manual-only and are polled only when triggered from the UI +or by running `scripts/qwk_poll.php` directly. + +--- + +## Polling and Packet Flow + +The transport cycle is driven by: + +- `php scripts/qwk_poll.php --all` +- `php scripts/qwk_poll.php --mailbox=` +- `php scripts/binkp_scheduler.php`, which also evaluates QWK mailbox + `poll_schedule` entries + +For each enabled mailbox, BinktermPHP: + +1. Downloads the remote `.QWK` packet, if one is available. +2. Imports mapped conferences into local echo areas. +3. Builds a `.REP` packet containing queued outbound messages for that mailbox. +4. Uploads the `.REP` packet back to the remote host. + +--- + +## Behavior + +- Inbound deduplication uses `(qwk_mailbox_id, qwk_conference_number, qwk_msg_number)`. +- Unknown conferences are auto-created into the built-in `qwk` network using the remote conference name as the description. The sysop can later move the area into a different network domain if needed. +- Outbound replies preserve QWK reply threading when the parent message came from the same mailbox and conference. +- Imported QWK messages obey the echo area's imported-message relay mode. In `auto` mode they can relay to other connected transport types; in `manual` mode only explicit rules apply; in `none` mode they are stored locally only. + +--- + +## Local-Only Areas + +Areas marked `is_local = true` may still appear in the logged-in user's own QWK +download from this BBS and may accept that same user's REP uploads back into +the same local area. + +Inter-BBS `qwk_poll` mailbox polling still runs normally when a mailbox has a +subscription that points at an `is_local` area. If the remote system delivers +new inbound QWK messages for that mapped conference, BinktermPHP may still +import those inbound messages into the local `is_local` area. + +Areas marked `is_local = true` must not be redistributed through inter-BBS QWK +mailbox fanout, even if QWK mailbox mappings exist elsewhere in the system. + +In other words: + +- Local offline-reader access is allowed for `is_local` areas. +- Inter-BBS inbound import into a mapped `is_local` area is allowed. +- External QWK mailbox transport is not allowed for `is_local` areas. + +--- + +## Related Docs + +- [QWK Offline Mail](QWK.md) — User-facing download/upload workflow on this BBS +- [Echo Areas](EchoAreas.md) — Echo area delivery rules and `is_local` behavior +- [API Reference](API.md) — QWK mailbox and QWK offline-mail endpoints diff --git a/docs/UPGRADING_1.9.8.md b/docs/UPGRADING_1.9.8.md index 18e3f31b7..cf2874f46 100644 --- a/docs/UPGRADING_1.9.8.md +++ b/docs/UPGRADING_1.9.8.md @@ -5,17 +5,40 @@ Make sure you have a current backup of your database and files before upgrading. ## Table of Contents - [Summary of Changes](#summary-of-changes) +- [InterBBS QWK](#interbbs-qwk) +- [Gating](#gating) +- [External Delivery](#external-delivery) +- [QWK FTP Service](#qwk-ftp-service) - [Web Interface](#web-interface) +- [Developer / Infrastructure](#developer--infrastructure) + - [Echo Area Deletion](#echo-area-deletion) - [Upgrade Instructions](#upgrade-instructions) - [From Git](#from-git) - [Using the Installer](#using-the-installer) ## Summary of Changes +### InterBBS QWK + +- BinktermPHP now supports inter-BBS QWK networking. The system can poll remote QWK mailboxes, import `.QWK` packets into mapped local echo areas, queue outbound replies into `.REP` packets, and upload those reply packets back to the remote BBS. +- Echo Areas now include per-area QWK conference mappings, and the scheduler can poll QWK mailboxes automatically when a mailbox `poll_schedule` is configured. +- User accounts now include an `is_bbs_account` flag for QWK hub/downlink scenarios. When enabled on a dedicated BBS user account, uploaded `.REP` packets preserve the remote packet `From` name instead of collapsing imported posts to the local account identity. + +### Gating + +- Echo Areas now support gates that mirror newly posted or newly imported messages into another local echo area. +- Gates are transport-neutral. After the mirrored copy is created, the destination area applies its own FTN or QWK outbound rules. + +### QWK FTP Service + +- QWK FTP service uploads now accept reply packets dropped directly into the FTP root (`/`) as well as `/qwk/upload/`, improving compatibility with clients that do not change into the upload subdirectory before sending `.REP` or `.ZIP` files. +- QWK FTP directory listings now eagerly build the user's QWK packet and report the actual packet size instead of a placeholder or stale size. + ### Web Interface - 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. +- Echo area deletion now offers a message-handling choice for populated areas. Sysops can delete the remaining messages or move them into another local echo area before removing the area itself. ### Developer / Infrastructure @@ -26,7 +49,59 @@ Make sure you have a current backup of your database and files before upgrading. --- -## Web Interface +## InterBBS QWK + +BinktermPHP can now participate in inter-BBS QWK exchange as an external message transport. This is separate from the existing same-BBS offline-reader workflow. + +The QWK networking implementation adds: + +- QWK mailbox definitions for remote BBS peers, including host, credentials, remote path, passive FTP mode, and optional poll schedule +- per-area QWK conference mappings so a local echo area can be linked to one or more remote mailbox conference numbers +- inbound `.QWK` packet import into mapped local areas +- outbound `.REP` packet generation and upload back to the remote BBS +- QWK source tracking and deduplication so imported messages are not re-imported or echoed straight back to the same mailbox + +Operational notes: + +- The scheduler now evaluates QWK mailbox `poll_schedule` entries. A blank schedule remains manual-only. +- Unknown inbound conferences can be auto-created into the built-in `qwk` network as placeholder mappings for later review. +- Outbound REP formatting was corrected for Synchronet-compatible conference parsing during reply import. +- If you are using one local account to represent a remote BBS peer, enable `Is BBS Account` on that user in Admin -> Users so imported REP messages retain the remote sender's name. + +## Gating + +Echo Areas now support gates between distinct local area records. A gate mirrors new traffic from one area into another area while keeping the two areas separate in the database. + +This is intended for cases such as: + +- carrying the same topic under different tags on different networks +- mirroring a local area into a networked area +- relaying between an FTN-backed area and a QWK-backed area + +Gate behavior: + +- Gates apply to new messages only. Historical messages are not replayed. +- The mirrored copy is stored as a separate local message in the destination area. +- Once stored, the destination area evaluates its own outbound routing. That means a gated copy may then spool to FTN, queue for inter-BBS QWK, or both. +- Loop protection uses source message identity so a gated copy returning from another network is not imported endlessly. +- Self-gates are not allowed. + +## External Delivery + +External delivery now follows a stricter split between local-only areas and networked areas: + +- `is_local = true` means the area is never propagated through any external transport layer. +- Local-only areas do not spool to FTN uplinks. +- Local-only areas do not fan out to inter-BBS QWK mailboxes. +- The only QWK behavior still allowed for a local-only area is the logged-in user's own offline-reader workflow on this BBS: the area can appear in that user's personal QWK packet, and replies uploaded by that same user can be imported back into the same local area. + +For non-local areas, external delivery is transport-specific: + +- FTN spooling is used only when the area's domain is backed by an FTN network type. +- QWK fanout is used when the area has QWK conference subscriptions. +- A single non-local area may participate in both transports if it is configured that way. + +## QWK FTP Service ### Subscription Manager @@ -42,6 +117,25 @@ The updated page adds: This change is user-facing only. It does not alter subscriptions, interest membership, or message access rules. +### FTP Root REP Uploads + +The FTP daemon now accepts `.REP` and `.ZIP` uploads dropped directly into the FTP root (`/`) in addition to the existing `/qwk/upload/` path. Previously, uploads to the root were rejected, blocking QWK client software, such as Synchronet's `qnet-ftp.js`, that stores the reply packet in the current working directory without issuing a `CWD` command first. + +Clients that already target `/qwk/upload/` are unaffected. Clients that upload to root now have their packet routed through the same REP import pipeline as a `/qwk/upload/` transfer, including the same conference-map validation and deduplication checks. + +The FTP-side QWK service now also builds the current user's outbound QWK packet eagerly when presenting the packet in a directory listing. That means FTP clients see the real downloadable packet size instead of a placeholder or an outdated size from a previous build. + +## Web Interface + +### Echo Area Deletion + +The Echo Areas admin page now allows populated areas to be deleted without manual SQL cleanup. When deleting an area that still contains echomail, the dialog offers two explicit choices: + +- delete the messages together with the area +- move the messages into another local echo area before deleting the original area + +The move option is a local reassignment only. It does not re-gate, re-spool, or republish the historical messages into the destination area's outbound network paths. + ## Developer / Infrastructure ### Realtime Signaling Abstraction @@ -70,6 +164,10 @@ Current scope: PostgreSQL is still the only supported platform. The new `DB_DRIVER` setting should remain `pgsql`. +### PostgreSQL Dependency Inventory + +A new developer reference document, `docs/PostgreSQLDependencies.md`, tracks intentional PostgreSQL-specific dependencies and where they currently live. + ## Upgrade Instructions ### From Git @@ -80,6 +178,8 @@ php scripts/setup.php scripts/restart_daemons.sh ``` +Recent 1.9.8 database updates include the `users.is_bbs_account` column. Running `php scripts/setup.php` is required so the new migration is applied before using the Admin -> Users checkbox or the REP `From`-name passthrough behavior. + ### Using the Installer Download the latest installer from the [BinktermPHP website](https://lovelybits.org/binktermphp) and run it. The installer handles file replacement, runs setup, and restarts all daemons automatically. diff --git a/docs/index.md b/docs/index.md index 7836e0a28..abfd8ad64 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ Complete reference for sysops and developers. New here? Start with [Getting Star - [FREQ](FREQ.md) — File request (FREQ) serving and requesting: modes, magic names, routing, and CLI tools - [LovlyNet](LovlyNet.md) — LovlyNet network file sharing and FileFix integration - [AreaFix / FileFix](AreaFix.md) — Managing echomail and file-area subscriptions with hub uplinks +- [QWK Networking](QWKNetworking.md) — Inter-BBS QWK mailbox transport, conference mapping, gating, and local-only delivery rules --- diff --git a/public_html/sw.js b/public_html/sw.js index 79e5b59e5..9915e9873 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v908'; +const CACHE_NAME = 'binkcache-v916'; // Static assets to precache const staticAssets = [ diff --git a/routes/admin-routes.php b/routes/admin-routes.php index a3d0ba052..2da060e9c 100644 --- a/routes/admin-routes.php +++ b/routes/admin-routes.php @@ -3130,7 +3130,6 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array apiError('errors.admin.networks.delete_in_use', apiLocalizedText('errors.admin.networks.delete_in_use', 'Network is in use'), 409); return; } - $manager->delete((int)$id); echo json_encode(['success' => true, 'message_code' => 'ui.admin.networks.deleted']); } catch (Throwable $e) { diff --git a/routes/api-routes.php b/routes/api-routes.php index ced34c7de..4af60f1dd 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -2347,14 +2347,16 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $stmt = $db->prepare(" INSERT INTO echoareas (tag, description, moderator, uplink_address, posting_name_policy, art_format_hint, color, is_active, is_local, is_sysop_only, domain, gemini_public, allow_media) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id "); $result = $stmt->execute([$tag, $description, $moderator, $uplinkAddress, $postingNamePolicy, $artFormatHint, $color, $isActive ? 'true' : 'false', $isLocal ? 'true' : 'false', $isSysopOnly ? 'true' : 'false', $domain, $geminiPublic ? 'true' : 'false', $allowMedia]); if ($result) { + $inserted = $stmt->fetch(PDO::FETCH_ASSOC); echo json_encode([ 'success' => true, - 'id' => $db->lastInsertId(), + 'id' => $inserted ? (int)$inserted['id'] : 0, 'message_code' => 'ui.echoareas.created_success' ]); } else { @@ -2500,31 +2502,129 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array try { $db = Database::getInstance()->getPdo(); + $payload = json_decode(file_get_contents('php://input'), true); + if (!is_array($payload)) { + $payload = []; + } - // Check if echo area has messages - $stmt = $db->prepare("SELECT COUNT(*) as count FROM echomail WHERE echoarea_id = ?"); - $stmt->execute([$id]); - $messageCount = $stmt->fetch()['count']; + $messageAction = isset($payload['message_action']) ? trim((string)$payload['message_action']) : ''; + $targetEchoareaId = isset($payload['target_echoarea_id']) ? (int)$payload['target_echoarea_id'] : 0; + + $rebuildEchoareaStats = function (\PDO $db, int $echoareaId): void { + $countStmt = $db->prepare("SELECT COUNT(*) FROM echomail WHERE echoarea_id = ?"); + $countStmt->execute([$echoareaId]); + $messageCount = (int)$countStmt->fetchColumn(); + + $latestStmt = $db->prepare(" + SELECT subject, from_name, date_received + FROM echomail + WHERE echoarea_id = ? + ORDER BY date_received DESC NULLS LAST, id DESC + LIMIT 1 + "); + $latestStmt->execute([$echoareaId]); + $latest = $latestStmt->fetch(\PDO::FETCH_ASSOC) ?: null; + + $updateStmt = $db->prepare(" + UPDATE echoareas + SET message_count = ?, + last_post_subject = ?, + last_post_author = ?, + last_post_date = ? + WHERE id = ? + "); + $updateStmt->execute([ + $messageCount, + $latest['subject'] ?? null, + $latest['from_name'] ?? null, + $latest['date_received'] ?? null, + $echoareaId + ]); + }; + + $db->beginTransaction(); + + $echoareaStmt = $db->prepare("SELECT id FROM echoareas WHERE id = ?"); + $echoareaStmt->execute([$id]); + if (!$echoareaStmt->fetch(\PDO::FETCH_ASSOC)) { + throw new \Exception('Echo area not found'); + } + + $countStmt = $db->prepare("SELECT COUNT(*) FROM echomail WHERE echoarea_id = ?"); + $countStmt->execute([$id]); + $messageCount = (int)$countStmt->fetchColumn(); if ($messageCount > 0) { - throw new \Exception("Cannot delete echo area with existing messages ($messageCount messages). Deactivate instead."); + if ($messageAction === '') { + throw new \Exception('Delete action required'); + } + + if (!in_array($messageAction, ['delete_messages', 'move_messages'], true)) { + throw new \Exception('Invalid delete action'); + } + + if ($messageAction === 'move_messages') { + if ($targetEchoareaId <= 0) { + throw new \Exception('Move target required'); + } + + if ($targetEchoareaId === (int)$id) { + throw new \Exception('Move target invalid'); + } + + $targetStmt = $db->prepare("SELECT id FROM echoareas WHERE id = ?"); + $targetStmt->execute([$targetEchoareaId]); + if (!$targetStmt->fetch(\PDO::FETCH_ASSOC)) { + throw new \Exception('Move target invalid'); + } + + $moveStmt = $db->prepare("UPDATE echomail SET echoarea_id = ? WHERE echoarea_id = ?"); + $moveStmt->execute([$targetEchoareaId, $id]); + $rebuildEchoareaStats($db, $targetEchoareaId); + } else { + $messageIdsStmt = $db->prepare("SELECT id FROM echomail WHERE echoarea_id = ?"); + $messageIdsStmt->execute([$id]); + $messageIds = array_map('intval', $messageIdsStmt->fetchAll(\PDO::FETCH_COLUMN)); + + if (!empty($messageIds)) { + $placeholders = implode(',', array_fill(0, count($messageIds), '?')); + $clearRepliesStmt = $db->prepare("UPDATE echomail SET reply_to_id = NULL WHERE reply_to_id IN ($placeholders)"); + $clearRepliesStmt->execute($messageIds); + } + + $deleteMessagesStmt = $db->prepare("DELETE FROM echomail WHERE echoarea_id = ?"); + $deleteMessagesStmt->execute([$id]); + } } - $stmt = $db->prepare("DELETE FROM echoareas WHERE id = ?"); - $result = $stmt->execute([$id]); + $deleteEchoareaStmt = $db->prepare("DELETE FROM echoareas WHERE id = ?"); + $deleteEchoareaStmt->execute([$id]); - if ($result && $stmt->rowCount() > 0) { - echo json_encode([ - 'success' => true, - 'message_code' => 'ui.echoareas.deleted_success' - ]); - } else { + if ($deleteEchoareaStmt->rowCount() < 1) { throw new \Exception('Echo area not found'); } + + $db->commit(); + + echo json_encode([ + 'success' => true, + 'message_code' => 'ui.echoareas.deleted_success' + ]); } catch (\Exception $e) { + if (isset($db) && $db instanceof \PDO && $db->inTransaction()) { + $db->rollBack(); + } http_response_code(400); $message = $e->getMessage(); - if (str_starts_with($message, 'Cannot delete echo area with existing messages')) { + if ($message === 'Delete action required') { + apiError('errors.echoareas.delete_action_required', apiLocalizedText('errors.echoareas.delete_action_required', 'Choose what to do with remaining messages before deleting this echo area', $user)); + } elseif ($message === 'Invalid delete action') { + apiError('errors.echoareas.delete_invalid_action', apiLocalizedText('errors.echoareas.delete_invalid_action', 'Invalid delete action selected', $user)); + } elseif ($message === 'Move target required') { + apiError('errors.echoareas.delete_move_target_required', apiLocalizedText('errors.echoareas.delete_move_target_required', 'Select a target echo area to move the remaining messages', $user)); + } elseif ($message === 'Move target invalid') { + apiError('errors.echoareas.delete_move_target_invalid', apiLocalizedText('errors.echoareas.delete_move_target_invalid', 'Selected target echo area is invalid', $user)); + } elseif (str_starts_with($message, 'Cannot delete echo area with existing messages')) { apiError('errors.echoareas.delete_blocked_has_messages', apiLocalizedText('errors.echoareas.delete_blocked_has_messages', 'Cannot delete echo area with existing messages', $user)); } elseif ($message === 'Echo area not found') { apiError('errors.echoareas.not_found', apiLocalizedText('errors.echoareas.not_found', 'Echo area not found', $user)); @@ -2553,6 +2653,219 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array ]); }); + SimpleRouter::get('/qwk-mailboxes', function() { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $manager = new \BinktermPHP\Qwk\QwkMailboxManager(); + echo json_encode(['mailboxes' => $manager->getAll()]); + }); + + SimpleRouter::get('/qwk-mailboxes/{id}', function($id) { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $manager = new \BinktermPHP\Qwk\QwkMailboxManager(); + $mailbox = $manager->getById((int)$id, true); + if (!$mailbox) { + http_response_code(404); + apiError('errors.qwk.uplink_not_found', apiLocalizedText('errors.qwk.uplink_not_found', 'QWK mailbox not found', $user)); + } + + echo json_encode(['mailbox' => $mailbox]); + })->where(['id' => '[0-9]+']); + + SimpleRouter::post('/qwk-mailboxes', function() { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $input = json_decode(file_get_contents('php://input'), true) ?? []; + + try { + $manager = new \BinktermPHP\Qwk\QwkMailboxManager(); + $id = $manager->save($input); + echo json_encode(['success' => true, 'id' => $id, 'message_code' => 'ui.qwk.uplinks.saved']); + } catch (\InvalidArgumentException $e) { + http_response_code(400); + apiError('errors.qwk.invalid_uplink', apiLocalizedText('errors.qwk.invalid_uplink', 'Invalid QWK mailbox configuration', $user)); + } catch (\PDOException $e) { + http_response_code(400); + apiError('errors.qwk.save_failed', apiLocalizedText('errors.qwk.save_failed', 'Failed to save QWK configuration', $user)); + } + }); + + SimpleRouter::put('/qwk-mailboxes/{id}', function($id) { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $input = json_decode(file_get_contents('php://input'), true) ?? []; + + try { + $manager = new \BinktermPHP\Qwk\QwkMailboxManager(); + $manager->save($input, (int)$id); + echo json_encode(['success' => true, 'message_code' => 'ui.qwk.uplinks.saved']); + } catch (\InvalidArgumentException $e) { + http_response_code(400); + apiError('errors.qwk.invalid_uplink', apiLocalizedText('errors.qwk.invalid_uplink', 'Invalid QWK mailbox configuration', $user)); + } catch (\PDOException $e) { + http_response_code(400); + apiError('errors.qwk.save_failed', apiLocalizedText('errors.qwk.save_failed', 'Failed to save QWK configuration', $user)); + } + })->where(['id' => '[0-9]+']); + + SimpleRouter::delete('/qwk-mailboxes/{id}', function($id) { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $manager = new \BinktermPHP\Qwk\QwkMailboxManager(); + if (!$manager->delete((int)$id)) { + http_response_code(404); + apiError('errors.qwk.uplink_not_found', apiLocalizedText('errors.qwk.uplink_not_found', 'QWK mailbox not found', $user)); + } + echo json_encode(['success' => true, 'message_code' => 'ui.qwk.uplinks.deleted']); + })->where(['id' => '[0-9]+']); + + SimpleRouter::post('/qwk-mailboxes/{id}/poll', function($id) { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + try { + $client = new \BinktermPHP\Admin\AdminDaemonClient(); + $result = $client->qwkPollSync((int)$id); + $client->close(); + } catch (\Throwable $e) { + $status = str_starts_with($e->getMessage(), 'Admin daemon error:') + ? 400 + : 500; + http_response_code($status); + apiError( + 'errors.qwk.poll_failed', + apiLocalizedText('errors.qwk.poll_failed', 'Failed to poll QWK mailbox', $user), + $status, + ['detail' => $e->getMessage()] + ); + } + + if (empty($result['success'])) { + http_response_code(400); + apiError( + 'errors.qwk.poll_failed', + apiLocalizedText('errors.qwk.poll_failed', 'Failed to poll QWK mailbox', $user), + 400, + ['detail' => $result['error'] ?? null] + ); + } + + echo json_encode(array_merge(['message_code' => 'ui.qwk.uplinks.polled'], $result)); + })->where(['id' => '[0-9]+']); + + SimpleRouter::get('/echoareas/{id}/qwk-config', function($id) { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $db = Database::getInstance()->getPdo(); + $echoareaId = (int)$id; + + $check = $db->prepare("SELECT id FROM echoareas WHERE id = ?"); + $check->execute([$echoareaId]); + if (!$check->fetchColumn()) { + http_response_code(404); + apiError('errors.echoareas.not_found', apiLocalizedText('errors.echoareas.not_found', 'Echo area not found', $user)); + } + + $subscriptionManager = new \BinktermPHP\Qwk\QwkSubscriptionManager($db); + $gateProcessor = new \BinktermPHP\Echomail\GateProcessor($db); + $mailboxManager = new \BinktermPHP\Qwk\QwkMailboxManager($db); + $relayPolicyManager = new \BinktermPHP\Echomail\RelayPolicyManager($db); + + $areasStmt = $db->prepare(" + SELECT id, tag, domain, description + FROM echoareas + WHERE id <> ? + ORDER BY LOWER(tag), LOWER(COALESCE(domain, '')) + "); + $areasStmt->execute([$echoareaId]); + + echo json_encode([ + 'subscriptions' => $subscriptionManager->getSubscriptionsForArea($echoareaId), + 'gates' => $gateProcessor->getGatesForArea($echoareaId), + 'relay_mode' => $relayPolicyManager->getModeForArea($echoareaId), + 'relay_rules' => $relayPolicyManager->getRulesForArea($echoareaId), + 'available_transports' => $relayPolicyManager->getAvailableTransportTypesForArea($echoareaId), + 'mailboxes' => $mailboxManager->getAll(), + 'available_areas' => $areasStmt->fetchAll(PDO::FETCH_ASSOC) ?: [], + ]); + })->where(['id' => '[0-9]+']); + + SimpleRouter::put('/echoareas/{id}/qwk-config', function($id) { + $user = RouteHelper::requireAuth(); + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.echoareas.admin_required', apiLocalizedText('errors.echoareas.admin_required', 'Admin privileges are required', $user)); + } + + header('Content-Type: application/json'); + $db = Database::getInstance()->getPdo(); + $echoareaId = (int)$id; + $input = json_decode(file_get_contents('php://input'), true) ?? []; + + $check = $db->prepare("SELECT id FROM echoareas WHERE id = ?"); + $check->execute([$echoareaId]); + if (!$check->fetchColumn()) { + http_response_code(404); + apiError('errors.echoareas.not_found', apiLocalizedText('errors.echoareas.not_found', 'Echo area not found', $user)); + } + + $subscriptions = is_array($input['subscriptions'] ?? null) ? $input['subscriptions'] : []; + $gates = is_array($input['gates'] ?? null) ? $input['gates'] : []; + $relayMode = (string)($input['relay_mode'] ?? \BinktermPHP\Echomail\RelayPolicyManager::MODE_AUTO); + $relayRules = is_array($input['relay_rules'] ?? null) ? $input['relay_rules'] : []; + + try { + $db->beginTransaction(); + (new \BinktermPHP\Echomail\RelayPolicyManager($db))->setModeForArea($echoareaId, $relayMode); + (new \BinktermPHP\Echomail\RelayPolicyManager($db))->replaceRulesForArea($echoareaId, $relayRules); + (new \BinktermPHP\Qwk\QwkSubscriptionManager($db))->replaceAreaSubscriptions($echoareaId, $subscriptions); + (new \BinktermPHP\Echomail\GateProcessor($db))->replaceAreaGates($echoareaId, $gates); + $db->commit(); + echo json_encode(['success' => true, 'message_code' => 'ui.qwk.echoarea_config_saved']); + } catch (\Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + http_response_code(400); + apiError('errors.qwk.save_failed', apiLocalizedText('errors.qwk.save_failed', 'Failed to save QWK configuration', $user)); + } + })->where(['id' => '[0-9]+']); + // File Areas API routes SimpleRouter::get('/fileareas', function() { $auth = new Auth(); @@ -6859,7 +7172,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array http_response_code(500); apiError('errors.messages.send.failed', apiLocalizedText('errors.messages.send.failed', 'Failed to send message', $user)); } - } catch (Exception $e) { + } catch (\Throwable $e) { getServerLogger()->error('[SEND] Exception: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); http_response_code(500); apiError('errors.messages.send.exception', apiLocalizedText('errors.messages.send.exception', 'Failed to send message', $user)); @@ -10275,7 +10588,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array try { $db = Database::getInstance()->getPdo(); - $stmt = $db->prepare("SELECT id, username, real_name, email, credit_balance, is_active, is_admin, is_system, echomail_moderation_forced, created_at, last_login FROM users WHERE id = ?"); + $stmt = $db->prepare("SELECT id, username, real_name, email, credit_balance, is_active, is_admin, is_system, is_bbs_account, echomail_moderation_forced, created_at, last_login FROM users WHERE id = ?"); $stmt->execute([$id]); $userData = $stmt->fetch(); @@ -10389,6 +10702,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1; $isAdmin = isset($_POST['is_admin']) ? (int)$_POST['is_admin'] : 0; $isSystem = isset($_POST['is_system']) ? (int)$_POST['is_system'] : 0; + $isBbsAccount = isset($_POST['is_bbs_account']) ? (int)$_POST['is_bbs_account'] : 0; $echomailModerationForced = isset($_POST['echomail_moderation_forced']) ? (int)$_POST['echomail_moderation_forced'] : 0; $password = $_POST['password'] ?? ''; @@ -10405,9 +10719,10 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array 'is_active = ?', 'is_admin = ?', 'is_system = ?', + 'is_bbs_account = ?', 'echomail_moderation_forced = ?' ]; - $updateParams = [$realName, $email ?: null, $isActive, $isAdmin, $isSystem, $echomailModerationForced ? 'true' : 'false']; + $updateParams = [$realName, $email ?: null, $isActive, $isAdmin, $isSystem, $isBbsAccount ? 'true' : 'false', $echomailModerationForced ? 'true' : 'false']; // Add password if provided if ($password) { @@ -10501,6 +10816,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1; $isAdmin = isset($_POST['is_admin']) ? (int)$_POST['is_admin'] : 0; $isSystem = isset($_POST['is_system']) ? (int)$_POST['is_system'] : 0; + $isBbsAccount = isset($_POST['is_bbs_account']) ? (int)$_POST['is_bbs_account'] : 0; // Validate required fields if (empty($username) || empty($realName) || empty($password)) { @@ -10551,8 +10867,8 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array // Create user $insertStmt = $db->prepare(" - INSERT INTO users (username, password_hash, real_name, email, is_active, is_admin, is_system, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) + INSERT INTO users (username, password_hash, real_name, email, is_active, is_admin, is_system, is_bbs_account, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) "); $insertStmt->execute([ @@ -10562,7 +10878,8 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $email ?: null, $isActive, $isAdmin, - $isSystem + $isSystem, + $isBbsAccount ? 'true' : 'false' ]); $newUserId = $db->lastInsertId(); diff --git a/scripts/binkp_scheduler.php b/scripts/binkp_scheduler.php index ae9066cf4..04d00ba2a 100755 --- a/scripts/binkp_scheduler.php +++ b/scripts/binkp_scheduler.php @@ -110,8 +110,9 @@ function setConsoleTitle(string $title): void if (isset($args['status'])) { $scheduler = new Scheduler($config, $logger); $status = $scheduler->getScheduleStatus(); + $qwkStatus = $scheduler->getQwkScheduleStatus(); - echo "=== POLLING SCHEDULE STATUS ===\n"; + echo "=== BINKP POLLING SCHEDULE STATUS ===\n"; foreach ($status as $address => $info) { $dueStatus = $info['due_now'] ? 'DUE NOW' : 'Scheduled'; $enabledStatus = $info['enabled'] ? 'Enabled' : 'Disabled'; @@ -122,6 +123,18 @@ function setConsoleTitle(string $title): void echo " Last poll: " . formatStatusTimestamp($info['last_poll']) . "\n"; echo " Next poll: " . formatStatusTimestamp($info['next_poll']) . "\n"; } + + echo "\n=== QWK MAILBOX POLLING SCHEDULE STATUS ===\n"; + foreach ($qwkStatus as $label => $info) { + $dueStatus = $info['due_now'] ? 'DUE NOW' : 'Scheduled'; + $enabledStatus = $info['enabled'] ? 'Enabled' : 'Disabled'; + + echo "\n{$label}:\n"; + echo " Schedule: {$info['schedule']}\n"; + echo " Status: {$enabledStatus} / {$dueStatus}\n"; + echo " Last poll: " . formatStatusTimestamp($info['last_poll']) . "\n"; + echo " Next poll: " . formatStatusTimestamp($info['next_poll']) . "\n"; + } exit(0); } @@ -153,6 +166,14 @@ function setConsoleTitle(string $title): void $schedule = $uplink['poll_schedule'] ?? '0 */4 * * *'; $logger->info(" - {$uplink['address']} [{$status}] ({$schedule})"); } + + $qwkSchedulerStatus = $scheduler->getQwkScheduleStatus(); + $logger->info("Configured QWK mailboxes: " . count($qwkSchedulerStatus)); + foreach ($qwkSchedulerStatus as $label => $info) { + $status = !empty($info['enabled']) ? 'enabled' : 'disabled'; + $schedule = (string)($info['schedule'] ?? 'Manual only'); + $logger->info(" - {$label} [{$status}] ({$schedule})"); + } if (function_exists('pcntl_async_signals')) { pcntl_async_signals(true); @@ -181,6 +202,15 @@ function setConsoleTitle(string $title): void } else { $logger->info("No scheduled polls due at this time"); } + + $qwkResults = $scheduler->processScheduledQwkPolls(); + if (!empty($qwkResults)) { + $successCount = count(array_filter($qwkResults, function($r) { return $r['success']; })); + $totalCount = count($qwkResults); + $logger->info("Processed {$totalCount} scheduled QWK polls ({$successCount} successful)"); + } else { + $logger->info("No scheduled QWK polls due at this time"); + } $outboundResults = $scheduler->pollIfOutbound(); if (!empty($outboundResults)) { diff --git a/scripts/qwk_poll.php b/scripts/qwk_poll.php new file mode 100755 index 000000000..9acf0835c --- /dev/null +++ b/scripts/qwk_poll.php @@ -0,0 +1,182 @@ +#!/usr/bin/env php +,1:array} + */ +function qwkPollParseArgs(array $argv): array +{ + $args = []; + $positional = []; + for ($i = 1; $i < count($argv); $i++) { + $arg = $argv[$i]; + if (strpos($arg, '--') === 0) { + if (strpos($arg, '=') !== false) { + [$key, $value] = explode('=', substr($arg, 2), 2); + $args[$key] = $value; + } else { + $args[substr($arg, 2)] = true; + } + } else { + $positional[] = $arg; + } + } + return [$args, $positional]; +} + +function qwkPollResolveLogLevel(string $levelName): int +{ + static $levels = [ + 'DEBUG' => 0, + 'INFO' => 1, + 'WARNING' => 2, + 'ERROR' => 3, + 'CRITICAL' => 4, + ]; + + $normalized = strtoupper(trim($levelName)); + return $levels[$normalized] ?? $levels['INFO']; +} + +function qwkPollLog(callable $writer, int $threshold, string $level, string $message): void +{ + if (qwkPollResolveLogLevel($level) < $threshold) { + return; + } + + $writer(sprintf('[%s] %s', strtoupper($level), $message)); +} + +[$args, $positional] = qwkPollParseArgs($argv); +if (isset($args['help'])) { + qwkPollShowUsage(); + exit(0); +} + +$quiet = isset($args['quiet']); +$json = isset($args['json']); +$logLevelName = isset($args['debug']) ? 'DEBUG' : (string)($args['log-level'] ?? 'INFO'); +$logThreshold = qwkPollResolveLogLevel($logLevelName); +$dryRun = isset($args['dry-run']); +$logFile = isset($args['log-file']) ? (string)$args['log-file'] : \BinktermPHP\Config::getLogPath('qwk_poll.log'); +$logToConsole = !isset($args['no-console']) && !$json && !$quiet; +$logger = new Logger($logFile, $logLevelName, $logToConsole); +$poller = new \BinktermPHP\Qwk\QwkPoller(); +$poller->setDryRun($dryRun); +$poller->setPreserveDebugArtifacts($logThreshold <= qwkPollResolveLogLevel('DEBUG')); +$poller->setLogger(static function (string $level, string $message) use ($logger, $logThreshold): void { + if (qwkPollResolveLogLevel($level) < $logThreshold) { + return; + } + $logger->log($level, $message); +}); + +try { + if (isset($args['all'])) { + $results = $poller->pollAllEnabled(); + $allOk = array_reduce($results, static function (bool $carry, array $result): bool { + return $carry && !empty($result['success']); + }, true); + + if ($json) { + echo json_encode([ + 'success' => $allOk, + 'results' => $results, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + exit($allOk ? 0 : 1); + } + + foreach ($results as $name => $result) { + if ($quiet) { + echo $name . ': ' . (!empty($result['success']) ? 'OK' : 'FAIL') . "\n"; + continue; + } + + echo '[' . $name . '] ' . (!empty($result['success']) ? 'SUCCESS' : 'FAILED') . "\n"; + if (!empty($result['imported']) || !empty($result['skipped'])) { + echo ' Imported: ' . (int)($result['imported'] ?? 0) . "\n"; + echo ' Skipped: ' . (int)($result['skipped'] ?? 0) . "\n"; + } + if (!empty($result['dry_run'])) { + echo " Dry run: yes\n"; + } + if (array_key_exists('uploaded', $result)) { + echo ' Uploaded REP: ' . (!empty($result['dry_run']) ? 'skipped (dry run)' : (!empty($result['uploaded']) ? 'yes' : 'no')) . "\n"; + } + if (!empty($result['error'])) { + echo ' Error: ' . $result['error'] . "\n"; + } + } + + exit($allOk ? 0 : 1); + } + + if ($positional === []) { + qwkPollShowUsage(); + exit(1); + } + + $mailboxId = (int)$positional[0]; + $result = $poller->pollMailbox($mailboxId); + if ($json) { + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + exit(!empty($result['success']) ? 0 : 1); + } + + if ($quiet) { + echo !empty($result['success']) ? "OK\n" : "FAIL\n"; + exit(!empty($result['success']) ? 0 : 1); + } + + echo (!empty($result['success']) ? 'SUCCESS' : 'FAILED') . "\n"; + if (!empty($result['imported']) || !empty($result['skipped'])) { + echo 'Imported: ' . (int)($result['imported'] ?? 0) . "\n"; + echo 'Skipped: ' . (int)($result['skipped'] ?? 0) . "\n"; + } + if (!empty($result['dry_run'])) { + echo "Dry run: yes\n"; + } + if (array_key_exists('uploaded', $result)) { + echo 'Uploaded REP: ' . (!empty($result['dry_run']) ? 'skipped (dry run)' : (!empty($result['uploaded']) ? 'yes' : 'no')) . "\n"; + } + if (!empty($result['error'])) { + echo 'Error: ' . $result['error'] . "\n"; + } + exit(!empty($result['success']) ? 0 : 1); +} catch (\Throwable $e) { + if ($json) { + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + exit(1); + } + if ($quiet) { + echo "FAIL\n"; + } else { + echo 'Error: ' . $e->getMessage() . "\n"; + } + exit(1); +} diff --git a/src/Admin/AdminDaemonClient.php b/src/Admin/AdminDaemonClient.php index 4c3eb6ee2..090d442d0 100644 --- a/src/Admin/AdminDaemonClient.php +++ b/src/Admin/AdminDaemonClient.php @@ -64,6 +64,16 @@ public function binkPollSync(string $upstream): array return $this->sendCommand('binkp_poll_sync', ['upstream' => $upstream]); } + public function qwkPoll(int $mailboxId): array + { + return $this->sendCommand('qwk_poll', ['mailbox_id' => $mailboxId]); + } + + public function qwkPollSync(int $mailboxId): array + { + return $this->sendCommand('qwk_poll_sync', ['mailbox_id' => $mailboxId]); + } + public function binkpAuthTest(string $domain): array { return $this->sendCommand('binkp_auth_test', ['domain' => $domain]); diff --git a/src/Admin/AdminDaemonServer.php b/src/Admin/AdminDaemonServer.php index 9d646085a..8213f2128 100644 --- a/src/Admin/AdminDaemonServer.php +++ b/src/Admin/AdminDaemonServer.php @@ -41,6 +41,7 @@ class AdminDaemonServer 'packets.log', 'multiplexing-server.log', 'binkp_poll.log', + 'qwk_poll.log', 'binkp_server.log', 'binkp_scheduler.log', 'admin_daemon.log', @@ -354,6 +355,42 @@ private function handleCommand($client, array $payload): void $this->logCommandResult('binkp_poll_sync', $result); $this->writeResponse($client, ['ok' => true, 'result' => $result]); break; + case 'qwk_poll': + $mailboxId = (int)($data['mailbox_id'] ?? 0); + if ($mailboxId <= 0) { + $this->writeResponse($client, ['ok' => false, 'error' => 'missing_mailbox_id']); + break; + } + + $this->spawnCommand([PHP_BINARY, 'scripts/qwk_poll.php', '--no-console', (string)$mailboxId]); + $this->logger->info("Spawned background qwk_poll for mailbox {$mailboxId}"); + $this->writeResponse($client, ['ok' => true, 'result' => ['exit_code' => 0, 'stdout' => '', 'stderr' => '']]); + break; + case 'qwk_poll_sync': + $mailboxId = (int)($data['mailbox_id'] ?? 0); + if ($mailboxId <= 0) { + $this->writeResponse($client, ['ok' => false, 'error' => 'missing_mailbox_id']); + break; + } + + $result = $this->runCommand([PHP_BINARY, 'scripts/qwk_poll.php', '--json', (string)$mailboxId]); + $this->logCommandResult('qwk_poll_sync', $result); + + $stdout = trim((string)($result['stdout'] ?? '')); + $payload = json_decode($stdout, true); + if (!is_array($payload)) { + $payload = [ + 'success' => ($result['exit_code'] ?? 1) === 0, + 'error' => $stdout !== '' ? $stdout : ((string)($result['stderr'] ?? '') ?: 'Failed to poll QWK mailbox'), + ]; + } + + $this->writeResponse($client, [ + 'ok' => !empty($payload['success']), + 'result' => $payload, + 'error' => $payload['error'] ?? 'qwk_poll_failed', + ]); + break; case 'binkp_auth_test': $domain = $data['domain'] ?? null; $address = $data['address'] ?? null; @@ -1072,7 +1109,8 @@ private function runCommand(array $command): array private function spawnCommand(array $command): void { if (PHP_OS_FAMILY === 'Windows') { - // Let the scheduler pick up the spooled outbound packet. + $escaped = implode(' ', array_map('escapeshellarg', $command)); + @pclose(@popen('start /B "" ' . $escaped . ' > NUL 2>&1', 'r')); return; } diff --git a/src/AdminController.php b/src/AdminController.php index 925c74189..60d1c4ffd 100644 --- a/src/AdminController.php +++ b/src/AdminController.php @@ -40,7 +40,7 @@ public function getAllUsers($page = 1, $limit = 25, $search = '') try { $sql = " - SELECT id, username, email, real_name, fidonet_address, created_at, last_login, last_reminded, is_active, is_admin, is_system + SELECT id, username, email, real_name, fidonet_address, created_at, last_login, last_reminded, is_active, is_admin, is_system, is_bbs_account FROM users WHERE username ILIKE ? OR real_name ILIKE ? OR email ILIKE ? OR fidonet_address ILIKE ? ORDER BY created_at DESC @@ -51,7 +51,7 @@ public function getAllUsers($page = 1, $limit = 25, $search = '') } catch (\PDOException $e) { // is_system column not yet present (migration v1.10.18 not run) — fall back $sql = " - SELECT id, username, email, real_name, fidonet_address, created_at, last_login, last_reminded, is_active, is_admin + SELECT id, username, email, real_name, fidonet_address, created_at, last_login, last_reminded, is_active, is_admin, is_bbs_account FROM users WHERE username ILIKE ? OR real_name ILIKE ? OR email ILIKE ? OR fidonet_address ILIKE ? ORDER BY created_at DESC @@ -148,8 +148,8 @@ public function createUser($data) $passwordHash = password_hash($data['password'], PASSWORD_DEFAULT); $stmt = $this->db->prepare(" - INSERT INTO users (username, password_hash, email, real_name, fidonet_address, is_active, is_admin) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (username, password_hash, email, real_name, fidonet_address, is_active, is_admin, is_bbs_account) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $result = $stmt->execute([ @@ -159,7 +159,8 @@ public function createUser($data) $data['real_name'] ?? null, $data['fidonet_address'] ?? null, isset($data['is_active']) ? ($data['is_active'] ? 1 : 0) : 1, - isset($data['is_admin']) ? ($data['is_admin'] ? 1 : 0) : 0 + isset($data['is_admin']) ? ($data['is_admin'] ? 1 : 0) : 0, + isset($data['is_bbs_account']) ? ($data['is_bbs_account'] ? 'true' : 'false') : 'false' ]); if ($result) { @@ -260,6 +261,11 @@ public function updateUser($userId, $data) $params[] = $data['is_admin'] ? 1 : 0; } + if (isset($data['is_bbs_account'])) { + $updates[] = 'is_bbs_account = ?'; + $params[] = $data['is_bbs_account'] ? 'true' : 'false'; + } + if (isset($data['echomail_moderation_forced'])) { $updates[] = 'echomail_moderation_forced = ?'; $params[] = $data['echomail_moderation_forced'] ? 'true' : 'false'; diff --git a/src/Auth.php b/src/Auth.php index aa24c400c..fdb017acf 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -55,7 +55,7 @@ public function login($username, $password, string $service = 'web') public function authenticateCredentials(string $username, string $password): array|false { $stmt = $this->db->prepare(' - SELECT id, username, real_name, email, is_admin, password_hash, created_at, last_login, location, fidonet_address + SELECT id, username, real_name, email, is_admin, is_bbs_account, password_hash, created_at, last_login, location, fidonet_address FROM users WHERE (LOWER(username) = LOWER(?) OR LOWER(real_name) = LOWER(?)) AND is_active = TRUE LIMIT 1 @@ -80,7 +80,7 @@ public function logout($sessionId) public function validateSession($sessionId) { $stmt = $this->db->prepare(' - SELECT s.user_id, u.username, u.real_name, u.email, u.is_admin, u.password_hash, u.created_at, u.last_login, u.location, u.about_me, u.fidonet_address + SELECT s.user_id, u.username, u.real_name, u.email, u.is_admin, u.is_bbs_account, u.password_hash, u.created_at, u.last_login, u.location, u.about_me, u.fidonet_address FROM user_sessions s JOIN users u ON s.user_id = u.id WHERE s.session_id = ? AND s.expires_at > NOW() AND u.is_active = TRUE diff --git a/src/BinkdProcessor.php b/src/BinkdProcessor.php index 1e82a39b3..c8f2d1923 100644 --- a/src/BinkdProcessor.php +++ b/src/BinkdProcessor.php @@ -56,6 +56,12 @@ class BinkdProcessor */ public bool $useGapDetectParser = false; + /** + * When true, imported echomail is stored without triggering cross-transport + * relay fanout or gate processing. Used by kept-packet rescans. + */ + private bool $suppressImportedFanout = false; + public function __construct() { $this->db = Database::getInstance()->getPdo(); @@ -324,19 +330,25 @@ public function processKeptPacket(string $filename): array $imported = 0; $failed = 0; - while (!feof($handle)) { - try { - $message = $this->readMessage($handle, $packetInfo); - if (!$message) { - continue; + $previousSuppressImportedFanout = $this->suppressImportedFanout; + $this->suppressImportedFanout = true; + try { + while (!feof($handle)) { + try { + $message = $this->readMessage($handle, $packetInfo); + if (!$message) { + continue; + } + $undeliverable = false; + $this->storeMessage($message, $packetInfo, false, $undeliverable); + $imported++; + } catch (\Exception $e) { + $failed++; + $this->log("[RESCAN] Failed to process message in $packetName: " . $e->getMessage()); } - $undeliverable = false; - $this->storeMessage($message, $packetInfo, false, $undeliverable); - $imported++; - } catch (\Exception $e) { - $failed++; - $this->log("[RESCAN] Failed to process message in $packetName: " . $e->getMessage()); } + } finally { + $this->suppressImportedFanout = $previousSuppressImportedFanout; } fclose($handle); @@ -1507,6 +1519,15 @@ private function storeEchomail($message, $packetInfo = null, $domain) ]); } + if ($newId > 0 && !$this->suppressImportedFanout) { + (new \BinktermPHP\MessageHandler())->finalizeImportedTransportDelivery( + $newId, + (string)$echoarea['tag'], + (string)$domain, + \BinktermPHP\Echomail\RelayPolicyManager::TRANSPORT_FTN + ); + } + //$this->log("[BINKD] Stored echomail in echoarea id ".$echoarea['id']." from=".$fromAddress." messageId=".$messageId." subject=".$message['subject']); } @@ -1838,14 +1859,18 @@ private function writeMessage($handle, $message) // Debug logging //$this->log("DEBUG: Writing message from: " . $fromAddress . " to: " . $toAddress); - list($origZone, $origNetNode) = explode(':', $fromAddress); - list($origNet, $origNodePoint) = explode('/', $origNetNode); - $origNode = explode('.', $origNodePoint)[0]; // Remove point if present - + $origParts = $this->parseFtnAddressParts($fromAddress); + $origZone = $origParts['zone']; + $origNet = $origParts['net']; + $origNodePoint = $origParts['node_point']; + $origNode = $origParts['node']; + // Parse destination address - list($destZone, $destNetNode) = explode(':', $toAddress); - list($destNet, $destNodePoint) = explode('/', $destNetNode); - $destNode = explode('.', $destNodePoint)[0]; // Remove point if present + $destParts = $this->parseFtnAddressParts($toAddress); + $destZone = $destParts['zone']; + $destNet = $destParts['net']; + $destNodePoint = $destParts['node_point']; + $destNode = $destParts['node']; // Message text with proper FTN control lines $messageText = $message['message_text']; @@ -2059,9 +2084,10 @@ private function writeMessage($handle, $message) $messageText .= "\r"; // Parse system address for SEEN-BY and PATH lines - list($zone, $netNode) = explode(':', $systemAddress); - list($net, $nodePoint) = explode('/', $netNode); - $hostNode = explode('.', $nodePoint)[0]; // Host node without point + $systemParts = $this->parseFtnAddressParts($systemAddress); + $net = $systemParts['net']; + $nodePoint = $systemParts['node_point']; + $hostNode = $systemParts['node']; // Add SEEN-BY line (required for echomail) - uses host node only $messageText .= "SEEN-BY: {$net}/{$hostNode}\r"; @@ -2079,6 +2105,45 @@ private function writeMessage($handle, $message) fwrite($handle, $messageText . "\0"); } + /** + * Parse an FTN address into normalized parts without emitting notices for malformed input. + * + * @return array{zone:int,net:int,node:int,point:int,node_point:string} + */ + private function parseFtnAddressParts(string $address): array + { + $address = trim($address); + if ($address === '') { + return [ + 'zone' => 0, + 'net' => 0, + 'node' => 0, + 'point' => 0, + 'node_point' => '0', + ]; + } + + $zoneParts = explode(':', $address, 2); + $zone = isset($zoneParts[1]) ? (int)trim($zoneParts[0]) : 0; + $netNode = isset($zoneParts[1]) ? trim($zoneParts[1]) : trim($zoneParts[0]); + + $netNodeParts = explode('/', $netNode, 2); + $net = (int)trim($netNodeParts[0]); + $nodePoint = isset($netNodeParts[1]) ? trim($netNodeParts[1]) : '0'; + + $nodePointParts = explode('.', $nodePoint, 2); + $node = (int)trim($nodePointParts[0]); + $point = isset($nodePointParts[1]) ? (int)trim($nodePointParts[1]) : 0; + + return [ + 'zone' => $zone, + 'net' => $net, + 'node' => $node, + 'point' => $point, + 'node_point' => $point > 0 ? $node . '.' . $point : (string)$node, + ]; + } + private function logPacket($filename, $direction, $status) { $stmt = $this->db->prepare(" diff --git a/src/Binkp/Connection/Scheduler.php b/src/Binkp/Connection/Scheduler.php index 315d01dcb..778c1f19d 100644 --- a/src/Binkp/Connection/Scheduler.php +++ b/src/Binkp/Connection/Scheduler.php @@ -21,6 +21,7 @@ use BinktermPHP\Admin\AdminDaemonClient; use BinktermPHP\Crashmail\CrashmailService; use BinktermPHP\Database; +use BinktermPHP\Qwk\QwkMailboxManager; class Scheduler { @@ -32,8 +33,11 @@ class Scheduler private $logger; private $client; private $lastPollTimes; + /** @var array Unix timestamps of last scheduled QWK mailbox polls */ + private $lastQwkPollTimes; private $crashmailService; private $db; + private $qwkMailboxManager; /** @var int Unix timestamp of last crashmail poll run */ private $lastCrashmailPoll = 0; /** @@ -54,11 +58,13 @@ public function __construct($config = null, $logger = null) $this->logger = $logger; $this->client = new AdminDaemonClient(); $this->lastPollTimes = []; + $this->lastQwkPollTimes = []; $this->lastOutboundPollTimes = []; $this->outboundQueueActiveStates = []; $this->iterationPolledAddresses = []; $this->crashmailService = new CrashmailService(); $this->db = Database::getInstance()->getPdo(); + $this->qwkMailboxManager = new QwkMailboxManager($this->db); } public function setLogger($logger) @@ -156,6 +162,82 @@ public function processScheduledPolls() return $results; } + /** + * @return array> + */ + public function checkScheduledQwkPolls(): array + { + $mailboxes = array_values(array_filter( + $this->qwkMailboxManager->getAll(), + static fn(array $mailbox): bool => !empty($mailbox['enabled']) && trim((string)($mailbox['poll_schedule'] ?? '')) !== '' + )); + $pollsDue = []; + + $this->log("Checking schedules for " . count($mailboxes) . " QWK mailboxes", 'DEBUG'); + + foreach ($mailboxes as $mailbox) { + $mailboxId = (int)($mailbox['id'] ?? 0); + $mailboxKey = $this->getQwkMailboxKey($mailboxId); + $label = $this->getQwkMailboxLabel($mailbox); + $schedule = trim((string)($mailbox['poll_schedule'] ?? '')); + + $lastPolledAt = $this->parseStatusTimestampToUnix((string)($mailbox['last_polled_at'] ?? '')); + if ($lastPolledAt > 0 && !isset($this->lastQwkPollTimes[$mailboxKey])) { + $this->lastQwkPollTimes[$mailboxKey] = $lastPolledAt; + } + + $isDue = $this->isScheduleDue($schedule, $mailboxKey, $this->lastQwkPollTimes); + $this->log("QWK schedule check: {$label} ({$schedule}) due=" . ($isDue ? 'yes' : 'no'), 'DEBUG'); + + if ($isDue) { + $pollsDue[] = $mailbox; + } + } + + return $pollsDue; + } + + /** + * @return array> + */ + public function processScheduledQwkPolls(): array + { + $pollsDue = $this->checkScheduledQwkPolls(); + $results = []; + + if (empty($pollsDue)) { + $this->log("No scheduled QWK polls due at this time", 'DEBUG'); + } + + foreach ($pollsDue as $mailbox) { + $mailboxId = (int)($mailbox['id'] ?? 0); + $mailboxKey = $this->getQwkMailboxKey($mailboxId); + $label = $this->getQwkMailboxLabel($mailbox); + + try { + $this->log("Scheduled QWK poll starting for: {$label}"); + $pollResult = $this->client->qwkPoll($mailboxId); + $pollSuccess = ($pollResult['exit_code'] ?? 1) === 0; + + $this->lastQwkPollTimes[$mailboxKey] = time(); + $results[$label] = [ + 'success' => $pollSuccess, + 'poll_result' => $pollResult, + ]; + $this->log("Scheduled QWK poll completed for: {$label}"); + } catch (\Exception $e) { + $this->log("Scheduled QWK poll failed for {$label}: " . $e->getMessage(), 'ERROR'); + $results[$label] = [ + 'success' => false, + 'error_code' => 'errors.qwk.poll_failed', + 'error' => 'Failed to poll QWK mailbox' + ]; + } + } + + return $results; + } + public function processAdvertisingCampaigns(bool $dryRun = false): array { try { @@ -314,9 +396,10 @@ public function pollIfOutbound() return $results; } - private function isScheduleDue($cronExpression, $address) + private function isScheduleDue($cronExpression, $key, ?array $lastPollTimes = null) { - $lastPoll = $this->lastPollTimes[$address] ?? 0; + $times = $lastPollTimes ?? $this->lastPollTimes; + $lastPoll = $times[$key] ?? 0; $now = time(); if ($now - $lastPoll < 60) { @@ -511,6 +594,11 @@ public function runDaemon($interval = 60) if (!empty($results)) { $this->log("Processed " . count($results) . " scheduled polls"); } + + $qwkResults = $this->processScheduledQwkPolls(); + if (!empty($qwkResults)) { + $this->log("Processed " . count($qwkResults) . " scheduled QWK polls"); + } $outboundResults = $this->pollIfOutbound(); if (!empty($outboundResults)) { @@ -663,6 +751,82 @@ public function getScheduleStatus() return $status; } + /** + * @return array> + */ + public function getQwkScheduleStatus(): array + { + $mailboxes = $this->qwkMailboxManager->getAll(); + $status = []; + + foreach ($mailboxes as $mailbox) { + $mailboxId = (int)($mailbox['id'] ?? 0); + $mailboxKey = $this->getQwkMailboxKey($mailboxId); + $label = $this->getQwkMailboxLabel($mailbox); + $schedule = trim((string)($mailbox['poll_schedule'] ?? '')); + $lastPolledAt = $this->parseStatusTimestampToUnix((string)($mailbox['last_polled_at'] ?? '')); + $lastPoll = $this->lastQwkPollTimes[$mailboxKey] ?? $lastPolledAt; + + if ($lastPoll > 0 && !isset($this->lastQwkPollTimes[$mailboxKey])) { + $this->lastQwkPollTimes[$mailboxKey] = $lastPoll; + } + + $status[$label] = [ + 'id' => $mailboxId, + 'name' => (string)($mailbox['name'] ?? ''), + 'bbs_id' => (string)($mailbox['bbs_id'] ?? ''), + 'schedule' => $schedule !== '' ? $schedule : 'Manual only', + 'enabled' => !empty($mailbox['enabled']), + 'last_poll' => $this->formatStatusTimestamp($lastPoll, 'Never'), + 'next_poll' => ($schedule !== '' && !empty($mailbox['enabled'])) + ? $this->formatStatusTimestamp($this->getNextCronTime($schedule, $lastPoll ?: time()), 'Unknown') + : 'Manual only', + 'due_now' => ($schedule !== '' && !empty($mailbox['enabled'])) + ? $this->isScheduleDue($schedule, $mailboxKey, $this->lastQwkPollTimes) + : false, + ]; + } + + return $status; + } + + private function getQwkMailboxKey(int $mailboxId): string + { + return 'qwk-mailbox-' . $mailboxId; + } + + /** + * @param array $mailbox + */ + private function getQwkMailboxLabel(array $mailbox): string + { + $name = trim((string)($mailbox['name'] ?? '')); + $bbsId = trim((string)($mailbox['bbs_id'] ?? '')); + if ($name !== '' && $bbsId !== '') { + return $name . ' (' . $bbsId . ')'; + } + if ($name !== '') { + return $name; + } + if ($bbsId !== '') { + return $bbsId; + } + return 'Mailbox #' . (int)($mailbox['id'] ?? 0); + } + + private function parseStatusTimestampToUnix(string $value): int + { + if ($value === '') { + return 0; + } + + try { + return (new \DateTimeImmutable($value))->getTimestamp(); + } catch (\Throwable $e) { + return 0; + } + } + private function formatStatusTimestamp(int $timestamp, string $fallback): string { if ($timestamp <= 0) { diff --git a/src/EchoareaManager.php b/src/EchoareaManager.php index 9cefe387f..fa8eda44f 100644 --- a/src/EchoareaManager.php +++ b/src/EchoareaManager.php @@ -158,6 +158,7 @@ public function createIfMissing(array $data, array $domains = []): int posting_name_policy, art_format_hint, color, is_active, is_local, is_sysop_only, domain, gemini_public ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id "); $insertStmt->execute([ $tag, @@ -174,7 +175,29 @@ public function createIfMissing(array $data, array $domains = []): int $geminiPublic ? 'true' : 'false', ]); - return (int)$this->db->lastInsertId(); + $row = $insertStmt->fetch(PDO::FETCH_ASSOC); + return $row ? (int)$row['id'] : 0; + } + + public function createQwkPlaceholderArea(string $conferenceName, int $conferenceNumber): int + { + $baseTag = $this->buildQwkPlaceholderTag($conferenceName, $conferenceNumber); + $tag = $baseTag; + $suffix = 2; + while ($this->findByTagAndDomains($tag, ['qwk'])) { + $tag = $this->truncateTagWithSuffix($baseTag, $suffix); + $suffix++; + } + + return $this->createIfMissing([ + 'tag' => $tag, + 'description' => trim($conferenceName) !== '' ? trim($conferenceName) : ('QWK Conference ' . $conferenceNumber), + 'domain' => 'qwk', + 'is_local' => false, + 'is_active' => true, + 'is_sysop_only' => false, + 'gemini_public' => false, + ], ['qwk']); } public function updateDescription(int $id, string $description): bool @@ -245,4 +268,23 @@ private function buildDomainWhereClause(array $domains): array return ['(' . implode(' OR ', $parts) . ')', $params]; } + + private function buildQwkPlaceholderTag(string $conferenceName, int $conferenceNumber): string + { + $normalized = strtoupper(trim($conferenceName)); + $normalized = preg_replace('/[^A-Z0-9]+/', '_', $normalized ?? ''); + $normalized = trim((string)$normalized, '_'); + if ($normalized === '') { + $normalized = 'CONF_' . $conferenceNumber; + } + + return substr('QWK_' . $normalized, 0, 50); + } + + private function truncateTagWithSuffix(string $baseTag, int $suffix): string + { + $suffixText = '_' . $suffix; + $maxBaseLength = max(1, 50 - strlen($suffixText)); + return substr($baseTag, 0, $maxBaseLength) . $suffixText; + } } diff --git a/src/Echomail/GateProcessor.php b/src/Echomail/GateProcessor.php new file mode 100644 index 000000000..c2a2eb3d6 --- /dev/null +++ b/src/Echomail/GateProcessor.php @@ -0,0 +1,217 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->messageHandler = $messageHandler ?? new MessageHandler(); + } + + /** + * @return array> + */ + public function getGatesForArea(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT g.id, + g.source_area_id, + g.target_area_id, + g.bidirectional, + src.tag AS source_tag, + src.domain AS source_domain, + tgt.tag AS target_tag, + tgt.domain AS target_domain + FROM echo_area_gates g + JOIN echoareas src ON src.id = g.source_area_id + JOIN echoareas tgt ON tgt.id = g.target_area_id + WHERE g.source_area_id = ? OR (g.bidirectional = TRUE AND g.target_area_id = ?) + ORDER BY g.id + "); + $stmt->execute([$echoareaId, $echoareaId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** + * @param array> $gates + */ + public function replaceAreaGates(int $echoareaId, array $gates): void + { + $this->db->prepare(" + DELETE FROM echo_area_gates + WHERE source_area_id = ? + OR (bidirectional = TRUE AND target_area_id = ?) + ")->execute([$echoareaId, $echoareaId]); + + if ($gates === []) { + return; + } + + $insertStmt = $this->db->prepare(" + INSERT INTO echo_area_gates (source_area_id, target_area_id, bidirectional, created_at) + VALUES (?, ?, ?, NOW()) + "); + + foreach ($gates as $gate) { + $targetId = (int)($gate['target_area_id'] ?? 0); + $bidirectional = !empty($gate['bidirectional']); + if ($targetId <= 0 || $targetId === $echoareaId) { + throw new \InvalidArgumentException('Invalid gate target'); + } + + if ($bidirectional) { + $sourceId = min($echoareaId, $targetId); + $normalizedTargetId = max($echoareaId, $targetId); + $insertStmt->execute([$sourceId, $normalizedTargetId, 'true']); + continue; + } + + $insertStmt->execute([$echoareaId, $targetId, 'false']); + } + } + + public function processMessageById(int $messageId): void + { + $message = $this->getMessage($messageId); + if ($message === null) { + return; + } + + $routes = $this->resolveRoutes((int)$message['echoarea_id']); + if ($routes === []) { + return; + } + + $sourceMsgId = trim((string)($message['source_msgid'] ?? '')); + if ($sourceMsgId === '') { + $sourceMsgId = trim((string)($message['message_id'] ?? '')); + } + if ($sourceMsgId === '') { + $sourceMsgId = 'local:' . $messageId; + } + + foreach ($routes as $targetAreaId) { + if ($this->alreadyGated($targetAreaId, $sourceMsgId)) { + continue; + } + + $replyToId = $this->resolveTargetReplyToId($message, $targetAreaId); + $this->messageHandler->importExternalEchomail([ + 'echoarea_id' => $targetAreaId, + 'from_name' => (string)$message['from_name'], + 'to_name' => (string)$message['to_name'], + 'subject' => (string)$message['subject'], + 'message_text' => (string)$message['message_text'], + 'from_address' => $message['from_address'] !== '' ? (string)$message['from_address'] : null, + 'reply_to_id' => $replyToId, + 'source_msgid' => $sourceMsgId, + 'apply_gates' => false, + ]); + } + } + + /** + * @return array|null + */ + private function getMessage(int $messageId): ?array + { + $stmt = $this->db->prepare(" + SELECT em.*, ea.tag, ea.domain + FROM echomail em + JOIN echoareas ea ON ea.id = em.echoarea_id + WHERE em.id = ? + "); + $stmt->execute([$messageId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + /** + * @return array + */ + private function resolveRoutes(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT source_area_id, target_area_id, bidirectional + FROM echo_area_gates + WHERE source_area_id = ? OR target_area_id = ? + "); + $stmt->execute([$echoareaId, $echoareaId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $targets = []; + foreach ($rows as $row) { + $source = (int)$row['source_area_id']; + $target = (int)$row['target_area_id']; + $bidirectional = !empty($row['bidirectional']); + + if ($source === $echoareaId) { + $targets[] = $target; + } elseif ($bidirectional && $target === $echoareaId) { + $targets[] = $source; + } + } + + return array_values(array_unique($targets)); + } + + private function alreadyGated(int $targetAreaId, string $sourceMsgId): bool + { + $stmt = $this->db->prepare(" + SELECT 1 + FROM echomail + WHERE echoarea_id = ? AND source_msgid = ? + LIMIT 1 + "); + $stmt->execute([$targetAreaId, $sourceMsgId]); + return (bool)$stmt->fetchColumn(); + } + + /** + * @param array $message + */ + private function resolveTargetReplyToId(array $message, int $targetAreaId): ?int + { + $replyToId = (int)($message['reply_to_id'] ?? 0); + if ($replyToId <= 0) { + return null; + } + + $stmt = $this->db->prepare(" + SELECT source_msgid, message_id + FROM echomail + WHERE id = ? + "); + $stmt->execute([$replyToId]); + $parent = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$parent) { + return null; + } + + $matchMsgId = trim((string)($parent['source_msgid'] ?: $parent['message_id'])); + if ($matchMsgId === '') { + return null; + } + + $lookup = $this->db->prepare(" + SELECT id + FROM echomail + WHERE echoarea_id = ? + AND (message_id = ? OR source_msgid = ?) + ORDER BY id ASC + LIMIT 1 + "); + $lookup->execute([$targetAreaId, $matchMsgId, $matchMsgId]); + $id = $lookup->fetchColumn(); + return $id ? (int)$id : null; + } +} diff --git a/src/Echomail/RelayPolicyManager.php b/src/Echomail/RelayPolicyManager.php new file mode 100644 index 000000000..a3d3ba3f6 --- /dev/null +++ b/src/Echomail/RelayPolicyManager.php @@ -0,0 +1,196 @@ +db = $db ?? Database::getInstance()->getPdo(); + } + + public function getModeForArea(int $echoareaId): string + { + $stmt = $this->db->prepare("SELECT relay_mode FROM echoareas WHERE id = ? LIMIT 1"); + $stmt->execute([$echoareaId]); + $mode = $stmt->fetchColumn(); + + return $this->normalizeMode(is_string($mode) ? $mode : self::MODE_AUTO); + } + + public function setModeForArea(int $echoareaId, string $mode): void + { + $stmt = $this->db->prepare("UPDATE echoareas SET relay_mode = ? WHERE id = ?"); + $stmt->execute([$this->normalizeMode($mode), $echoareaId]); + } + + /** + * @return array> + */ + public function getRulesForArea(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT id, echoarea_id, origin_type, target_type, is_allowed, created_at + FROM echo_area_relay_rules + WHERE echoarea_id = ? + ORDER BY LOWER(origin_type), LOWER(target_type), id + "); + $stmt->execute([$echoareaId]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** + * @param array> $rules + */ + public function replaceRulesForArea(int $echoareaId, array $rules): void + { + $this->db->prepare("DELETE FROM echo_area_relay_rules WHERE echoarea_id = ?") + ->execute([$echoareaId]); + + if ($rules === []) { + return; + } + + $insertStmt = $this->db->prepare(" + INSERT INTO echo_area_relay_rules + (echoarea_id, origin_type, target_type, is_allowed) + VALUES (?, ?, ?, ?) + "); + + foreach ($rules as $rule) { + $originType = $this->normalizeTransportType((string)($rule['origin_type'] ?? '')); + $targetType = $this->normalizeTransportType((string)($rule['target_type'] ?? '')); + if ($originType === '' || $targetType === '' || $originType === $targetType) { + throw new \InvalidArgumentException('Invalid relay rule payload'); + } + + $isAllowed = !array_key_exists('is_allowed', $rule) || !empty($rule['is_allowed']); + $insertStmt->execute([ + $echoareaId, + $originType, + $targetType, + $isAllowed ? 'true' : 'false', + ]); + } + } + + public function shouldRelayImportedMessage(int $echoareaId, string $originType, string $targetType): bool + { + $originType = $this->normalizeTransportType($originType); + $targetType = $this->normalizeTransportType($targetType); + if ($originType === '' || $targetType === '' || $originType === $targetType) { + return false; + } + + $mode = $this->getModeForArea($echoareaId); + if ($mode === self::MODE_NONE) { + return false; + } + if ($mode === self::MODE_AUTO) { + return true; + } + + $stmt = $this->db->prepare(" + SELECT is_allowed + FROM echo_area_relay_rules + WHERE echoarea_id = ? + AND origin_type = ? + AND target_type = ? + LIMIT 1 + "); + $stmt->execute([$echoareaId, $originType, $targetType]); + $allowed = $stmt->fetchColumn(); + + return $allowed !== false && filter_var($allowed, FILTER_VALIDATE_BOOLEAN); + } + + /** + * @return array + */ + public function getAvailableTransportTypesForArea(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT e.domain, + e.is_local, + EXISTS ( + SELECT 1 + FROM echo_area_qwk_subscriptions s + WHERE s.echoarea_id = e.id + ) AS has_qwk + FROM echoareas e + WHERE e.id = ? + LIMIT 1 + "); + $stmt->execute([$echoareaId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return []; + } + + $types = []; + if ($this->isFtnRoutableArea($row)) { + $types[] = self::TRANSPORT_FTN; + } + if (!empty($row['has_qwk']) && empty($row['is_local'])) { + $types[] = self::TRANSPORT_QWK; + } + + return $types; + } + + private function normalizeMode(string $mode): string + { + $mode = strtolower(trim($mode)); + if (in_array($mode, [self::MODE_NONE, self::MODE_AUTO, self::MODE_MANUAL], true)) { + return $mode; + } + + return self::MODE_AUTO; + } + + private function normalizeTransportType(string $type): string + { + $type = strtolower(trim($type)); + if ($type === '' || !preg_match('/^[a-z0-9_]+$/', $type)) { + return ''; + } + + return $type; + } + + /** + * @param array $echoarea + */ + private function isFtnRoutableArea(array $echoarea): bool + { + if (!empty($echoarea['is_local'])) { + return false; + } + + $domain = strtolower(trim((string)($echoarea['domain'] ?? ''))); + if ($domain === '') { + return false; + } + + $network = (new NetworkManager($this->db))->getByDomain($domain); + + return (int)($network['network_type'] ?? 0) === NetworkManager::NETWORK_TYPE_FIDONET; + } +} diff --git a/src/Ftp/FtpServer.php b/src/Ftp/FtpServer.php index 15c70a16f..7e9d2541e 100644 --- a/src/Ftp/FtpServer.php +++ b/src/Ftp/FtpServer.php @@ -429,6 +429,8 @@ private function handlePass(int $clientId, string $password): void $clientId )); + $this->vfs->prebuildQwkPacket((array)$user, $this->clients[$clientId]); + $this->sendResponse($clientId, 230, 'Login successful'); } @@ -735,7 +737,10 @@ private function finalizeReceiveTransfer(int $clientId): void @fclose($transfer['temp_handle']); try { $targetPath = (string)$transfer['target_path']; - if (str_starts_with($targetPath, '/qwk/upload/')) { + $targetExt = strtolower((string)pathinfo(basename($targetPath), PATHINFO_EXTENSION)); + $isQwkDrop = str_starts_with($targetPath, '/qwk/upload/') + || (in_array($targetExt, ['rep', 'zip'], true) && substr_count($targetPath, '/') === 1); + if ($isQwkDrop) { $result = $this->vfs->importUploadedRep( (array)$this->clients[$clientId]['user'], $targetPath, diff --git a/src/Ftp/FtpVirtualFilesystem.php b/src/Ftp/FtpVirtualFilesystem.php index 96e96ee79..a322b28bb 100644 --- a/src/Ftp/FtpVirtualFilesystem.php +++ b/src/Ftp/FtpVirtualFilesystem.php @@ -55,7 +55,7 @@ public function listDirectory(array $user, array &$session, string $path): array { $resolved = $this->normalizePath('/', $path); if ($resolved === '/') { - return $this->listRoot($user); + return $this->listRoot($user, $session); } if ($this->isAnonymousUser($user)) { if ($resolved === '/fileareas') { @@ -69,7 +69,7 @@ public function listDirectory(array $user, array &$session, string $path): array return $this->listQwkRoot(); } if ($resolved === '/qwk/download') { - return $this->listQwkDownload($user); + return $this->listQwkDownload($user, $session); } if ($resolved === '/qwk/upload') { return $this->listQwkUpload($user); @@ -473,6 +473,14 @@ public function storeIncomingUpload(array $user, string $path, string $tempPath) } } + public function prebuildQwkPacket(array $user, array &$session): void + { + if ($this->isAnonymousUser($user) || !BbsConfig::isFeatureEnabled('qwk')) { + return; + } + $this->ensureQwkDownloadPacket($user, $session); + } + public function cleanupSession(array &$session): void { $packet = $session['qwk_download_packet'] ?? null; @@ -491,11 +499,16 @@ public function cleanupSession(array &$session): void /** * @return array */ - private function listRoot(array $user): array + private function listRoot(array $user, array &$session): array { $entries = []; if (!$this->isAnonymousUser($user) && BbsConfig::isFeatureEnabled('qwk')) { $entries[] = ['name' => 'qwk', 'type' => 'dir', 'size' => 0, 'mtime' => time()]; + $metadata = $this->qwkController->getDownloadMetadata((int)$user['id']); + $cachedPacket = $session['qwk_download_packet'] ?? null; + $size = (is_array($cachedPacket) && isset($cachedPacket['filesize'])) ? (int)$cachedPacket['filesize'] : 0; + $mtime = (is_array($cachedPacket) && isset($cachedPacket['mtime'])) ? (int)$cachedPacket['mtime'] : time(); + $entries[] = ['name' => $metadata['filename'], 'type' => 'file', 'size' => $size, 'mtime' => $mtime]; } if (FileAreaManager::isFeatureEnabled()) { $entries[] = ['name' => 'fileareas', 'type' => 'dir', 'size' => 0, 'mtime' => time()]; @@ -524,18 +537,21 @@ private function listQwkRoot(): array /** * @return array */ - private function listQwkDownload(array $user): array + private function listQwkDownload(array $user, array &$session): array { if (!BbsConfig::isFeatureEnabled('qwk')) { return []; } $metadata = $this->qwkController->getDownloadMetadata((int)$user['id']); + $cachedPacket = $session['qwk_download_packet'] ?? null; + $size = (is_array($cachedPacket) && isset($cachedPacket['filesize'])) ? (int)$cachedPacket['filesize'] : 0; + $mtime = (is_array($cachedPacket) && isset($cachedPacket['mtime'])) ? (int)$cachedPacket['mtime'] : time(); return [[ 'name' => $metadata['filename'], 'type' => 'file', - 'size' => 0, - 'mtime' => time(), + 'size' => $size, + 'mtime' => $mtime, ]]; } @@ -1257,17 +1273,24 @@ private function isQwkDownloadFile(array $user, string $path): bool } $metadata = $this->qwkController->getDownloadMetadata((int)$user['id']); - return $path === '/qwk/download/' . $metadata['filename']; + return $path === '/qwk/download/' . $metadata['filename'] + || $path === '/' . $metadata['filename']; } private function isQwkUploadFile(string $path): bool { - if (!str_starts_with($path, '/qwk/upload/')) { + $extension = strtolower((string)pathinfo(basename($path), PATHINFO_EXTENSION)); + if (!in_array($extension, ['rep', 'zip'], true)) { return false; } - $extension = strtolower((string)pathinfo(basename($path), PATHINFO_EXTENSION)); - return in_array($extension, ['rep', 'zip'], true); + // Standard path: /qwk/upload/BBSID.REP + if (str_starts_with($path, '/qwk/upload/')) { + return true; + } + + // Root-level drop: /BBSID.REP — Synchronet's QNET-FTP uploads without CWD-ing first + return substr_count($path, '/') === 1; } /** diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 9a48a8d7c..d00e4d0ac 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -1716,7 +1716,7 @@ private function sendLocalSysopMessage($fromUserId, $subject, $messageText, $fro /** * @param bool $skipCredits If true, skip awarding credits (used for cross-posted copies) */ - public function postEchomail($fromUserId, $echoareaTag, $domain, $toName, $subject, $messageText, $replyToId = null, $tagline = null, $skipCredits = false, $markupType = null, $prependKludges = '', $tearlineComponent = null, $charset = null) + public function postEchomail($fromUserId, $echoareaTag, $domain, $toName, $subject, $messageText, $replyToId = null, $tagline = null, $skipCredits = false, $markupType = null, $prependKludges = '', $tearlineComponent = null, $charset = null, $fromNameOverride = null) { $user = $this->getUserById($fromUserId); if (!$user) { @@ -1729,21 +1729,22 @@ public function postEchomail($fromUserId, $echoareaTag, $domain, $toName, $subje } $isLocalArea = !empty($echoarea['is_local']); + $isFtnRoutable = $this->isFtnRoutableEchoarea($echoarea, (string)$domain); $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); - // Determine the from address - $myAddress = $binkpConfig->getMyAddressByDomain($domain); - if (!$myAddress) { - if ($isLocalArea) { - // For local echoareas, use system address as fallback - $myAddress = $binkpConfig->getSystemAddress(); - } else { - throw new \Exception('Can not determine sending address for this network - missing uplink?'); + // Determine the from address. Non-FTN areas still need a stable local address + // for kludge generation and reply tracking, but they must not require an FTN uplink. + if ($isFtnRoutable) { + $myAddress = $binkpConfig->getMyAddressByDomain($domain); + if (!$myAddress) { + throw new \Exception('Can not determine sending address for this FTN network - missing uplink?'); } + } else { + $myAddress = $binkpConfig->getSystemAddress(); } - // Verify outbound directory is writable (only needed for non-local areas) - if (!$isLocalArea) { + // Verify outbound directory is writable only when this area can actually emit FTN packets. + if ($isFtnRoutable) { $outboundPath = $binkpConfig->getOutboundPath(); if (!is_dir($outboundPath) || !is_writable($outboundPath)) { $this->logger->error("[ECHOMAIL] Outbound directory not writable: {$outboundPath}"); @@ -1752,7 +1753,10 @@ public function postEchomail($fromUserId, $echoareaTag, $domain, $toName, $subje } // Generate kludges for this echomail - $fromName = $this->resolveEchomailPostingName($user, $echoarea, (string)$domain); + $fromName = trim((string)$fromNameOverride); + if ($fromName === '') { + $fromName = $this->resolveEchomailPostingName($user, $echoarea, (string)$domain); + } $toName = $toName ?: 'All'; $markupAllowed = null; try { @@ -1893,12 +1897,137 @@ public function postEchomail($fromUserId, $echoareaTag, $domain, $toName, $subje return 'pending'; } - $this->spoolOutboundEchomail($messageId, $echoareaTag, $domain); + $this->finalizeApprovedEchomailDelivery($messageId, $echoareaTag, $domain); } return $messageId > 0; } + /** + * Import an externally-sourced echomail message into a local echo area. + * + * Used for QWK network exchange and gated copies. Messages imported through + * this path are always approved immediately. Transport imports can opt into + * relay-policy fanout; gated copies continue to use the target area's + * normal outbound delivery rules. + * + * @param array $data + */ + public function importExternalEchomail(array $data): int + { + $echoareaId = isset($data['echoarea_id']) ? (int)$data['echoarea_id'] : 0; + $echoarea = $echoareaId > 0 ? $this->getEchoareaById($echoareaId) : null; + if (!$echoarea) { + $echoarea = $this->getEchoareaByTag((string)($data['echoarea_tag'] ?? ''), (string)($data['domain'] ?? '')); + } + if (!$echoarea) { + throw new \Exception('Echo area not found'); + } + + $domain = (string)($echoarea['domain'] ?? ''); + $isLocalArea = !empty($echoarea['is_local']); + $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); + $localAddress = $binkpConfig->getMyAddressByDomain($domain); + if (!$localAddress) { + $localAddress = $binkpConfig->getSystemAddress(); + } + + $fromName = trim((string)($data['from_name'] ?? 'Unknown')); + $toName = trim((string)($data['to_name'] ?? 'All')); + $subject = trim((string)($data['subject'] ?? '(no subject)')); + $messageText = (string)($data['message_text'] ?? ''); + $replyToId = !empty($data['reply_to_id']) ? (int)$data['reply_to_id'] : null; + $sourceMsgId = trim((string)($data['source_msgid'] ?? '')); + $storedFromAddress = trim((string)($data['from_address'] ?? '')); + $packetCharset = strtoupper(trim((string)($data['charset'] ?? 'UTF-8'))); + + $kludgeLines = $this->generateEchomailKludges( + $localAddress, + $fromName, + $toName, + $subject, + (string)$echoarea['tag'], + $replyToId, + null, + $domain, + $packetCharset + ); + + $msgId = null; + if (preg_match('/\x01MSGID:\s*(.+?)$/m', $kludgeLines, $matches)) { + $msgId = trim($matches[1]); + } + + $storage = $this->prepareLocalMessageStorage($messageText); + + $stmt = $this->db->prepare(" + INSERT INTO echomail ( + echoarea_id, from_address, from_name, to_name, subject, message_text, + raw_message_bytes, message_charset, art_format, date_written, reply_to_id, + message_id, origin_line, kludge_lines, bottom_kludges, tearline_component, + user_id, moderation_status, qwk_mailbox_id, qwk_conference_number, + qwk_msg_number, source_msgid + ) + VALUES ( + :echoarea_id, :from_address, :from_name, :to_name, :subject, :message_text, + :raw_message_bytes, :message_charset, :art_format, NOW(), :reply_to_id, + :message_id, NULL, :kludge_lines, NULL, NULL, + NULL, 'approved', :qwk_mailbox_id, :qwk_conference_number, + :qwk_msg_number, :source_msgid + ) + RETURNING id + "); + + $stmt->bindValue(':echoarea_id', (int)$echoarea['id'], \PDO::PARAM_INT); + $stmt->bindValue(':from_address', $storedFromAddress !== '' ? $storedFromAddress : null, $storedFromAddress !== '' ? \PDO::PARAM_STR : \PDO::PARAM_NULL); + $stmt->bindValue(':from_name', $fromName); + $stmt->bindValue(':to_name', $toName); + $stmt->bindValue(':subject', $subject); + $stmt->bindValue(':message_text', $storage['message_text']); + $stmt->bindValue(':raw_message_bytes', $storage['raw_message_bytes'] !== '' ? $storage['raw_message_bytes'] : null, $storage['raw_message_bytes'] !== '' ? \PDO::PARAM_LOB : \PDO::PARAM_NULL); + $stmt->bindValue(':message_charset', $storage['message_charset']); + $stmt->bindValue(':art_format', $storage['art_format']); + $stmt->bindValue(':reply_to_id', $replyToId, $replyToId !== null ? \PDO::PARAM_INT : \PDO::PARAM_NULL); + $stmt->bindValue(':message_id', $msgId); + $stmt->bindValue(':kludge_lines', $kludgeLines); + $stmt->bindValue(':qwk_mailbox_id', !empty($data['qwk_mailbox_id']) ? (int)$data['qwk_mailbox_id'] : null, !empty($data['qwk_mailbox_id']) ? \PDO::PARAM_INT : \PDO::PARAM_NULL); + $stmt->bindValue(':qwk_conference_number', isset($data['qwk_conference_number']) ? (int)$data['qwk_conference_number'] : null, isset($data['qwk_conference_number']) ? \PDO::PARAM_INT : \PDO::PARAM_NULL); + $stmt->bindValue(':qwk_msg_number', isset($data['qwk_msg_number']) ? (int)$data['qwk_msg_number'] : null, isset($data['qwk_msg_number']) ? \PDO::PARAM_INT : \PDO::PARAM_NULL); + $stmt->bindValue(':source_msgid', $sourceMsgId !== '' ? $sourceMsgId : null, $sourceMsgId !== '' ? \PDO::PARAM_STR : \PDO::PARAM_NULL); + $stmt->execute(); + + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $messageId = $row ? (int)$row['id'] : 0; + if ($messageId <= 0) { + return 0; + } + + $this->incrementEchoareaCount((int)$echoarea['id'], $subject, $fromName); + $excludeQwkMailboxId = isset($data['exclude_qwk_mailbox_id']) ? (int)$data['exclude_qwk_mailbox_id'] : null; + $applyGates = !array_key_exists('apply_gates', $data) || !empty($data['apply_gates']); + if (!empty($data['use_relay_policy'])) { + $originType = trim((string)($data['origin_type'] ?? '')); + $this->finalizeImportedTransportDelivery( + $messageId, + (string)$echoarea['tag'], + $domain, + $originType, + $excludeQwkMailboxId, + $applyGates + ); + } else { + $this->finalizeApprovedEchomailDelivery( + $messageId, + (string)$echoarea['tag'], + $domain, + $excludeQwkMailboxId, + $applyGates + ); + } + + return $messageId; + } + /** * Approve a pending echomail message: mark it approved, spool it for * transmission, and auto-promote the author if they have reached the @@ -1949,7 +2078,7 @@ public function approveEchomail(int $messageId): bool $echoareaTag = $message['echoarea_tag']; $domain = $message['echoarea_domain'] ?? ''; - $this->spoolOutboundEchomail($messageId, $echoareaTag, $domain); + $this->finalizeApprovedEchomailDelivery($messageId, $echoareaTag, $domain); // Check whether the author should be auto-promoted $userId = $message['user_id'] ? (int)$message['user_id'] : null; @@ -2668,6 +2797,13 @@ private function getEchoareaByTag($tag, $domain) return $stmt->fetch(); } + private function getEchoareaById(int $echoareaId) + { + $stmt = $this->db->prepare("SELECT * FROM echoareas WHERE id = ? AND is_active = TRUE"); + $stmt->execute([$echoareaId]); + return $stmt->fetch(); + } + /** * Resolve the display/sender name used for outbound echomail posting. * @@ -3189,20 +3325,37 @@ private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) return false; } - // Check if this is a local-only echoarea + // Local-only areas never propagate through any external transport. They remain + // readable in the local UI and the user's own offline-reader workflow only. if (!empty($message['is_local'])) { - //error_log("[SPOOL] Echomail #{$messageId} in local-only area {$echoareaTag} - not spooling to uplink"); $fromName = $message['from_name'] ?? 'unknown'; $fromAddr = $message['from_address'] ?? 'unknown'; - \BinktermPHP\Admin\AdminDaemonClient::log('INFO', 'echomail posted (local area)', [ + \BinktermPHP\Admin\AdminDaemonClient::log('INFO', 'echomail posted (local-only area)', [ 'area' => $echoareaTag, 'from' => "{$fromName} <{$fromAddr}>", 'to' => $message['to_name'] ?? 'All', 'subject' => $message['subject'] ?? '(no subject)', 'msgid' => $message['message_id'] ?? '', - 'packet' => '(local)', + 'packet' => '(local-only)', ]); - return true; // Success - message stored locally, no upstream transmission needed + return true; + } + + if (!$this->isFtnRoutableEchoarea($message, (string)($message['echoarea_domain'] ?? $domain))) { + $fromName = $message['from_name'] ?? 'unknown'; + $fromAddr = $message['from_address'] ?? 'unknown'; + $areaTag = $message['echoarea_tag'] ?? $echoareaTag; + + \BinktermPHP\Admin\AdminDaemonClient::log('INFO', 'echomail posted (non-FTN area)', [ + 'area' => $areaTag, + 'from' => "{$fromName} <{$fromAddr}>", + 'to' => $message['to_name'] ?? 'All', + 'subject' => $message['subject'] ?? '(no subject)', + 'msgid' => $message['message_id'] ?? '', + 'packet' => '(no-ftn-spool)', + ]); + + return true; } // Extract message details for logging @@ -3259,6 +3412,116 @@ private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) } } + private function finalizeApprovedEchomailDelivery(int $messageId, string $echoareaTag, ?string $domain, ?int $excludeQwkMailboxId = null, bool $applyGates = true): void + { + $this->spoolOutboundEchomail($messageId, $echoareaTag, (string)$domain); + $this->queueQwkOutboundEchomail($messageId, $excludeQwkMailboxId); + if ($applyGates) { + (new \BinktermPHP\Echomail\GateProcessor($this->db, $this))->processMessageById($messageId); + } + } + + /** + * Apply per-area transport relay policy to a transport-imported echomail row. + */ + public function finalizeImportedTransportDelivery( + int $messageId, + string $echoareaTag, + ?string $domain, + string $originType, + ?int $excludeQwkMailboxId = null, + bool $applyGates = true + ): void { + $stmt = $this->db->prepare(" + SELECT em.echoarea_id + FROM echomail em + WHERE em.id = ? + LIMIT 1 + "); + $stmt->execute([$messageId]); + $echoareaId = (int)$stmt->fetchColumn(); + if ($echoareaId <= 0) { + return; + } + + $relayPolicy = new \BinktermPHP\Echomail\RelayPolicyManager($this->db); + if ($relayPolicy->shouldRelayImportedMessage($echoareaId, $originType, \BinktermPHP\Echomail\RelayPolicyManager::TRANSPORT_FTN)) { + $this->spoolOutboundEchomail($messageId, $echoareaTag, (string)$domain); + } + if ($relayPolicy->shouldRelayImportedMessage($echoareaId, $originType, \BinktermPHP\Echomail\RelayPolicyManager::TRANSPORT_QWK)) { + $this->queueQwkOutboundEchomail($messageId, $excludeQwkMailboxId); + } + if ($applyGates) { + (new \BinktermPHP\Echomail\GateProcessor($this->db, $this))->processMessageById($messageId); + } + } + + private function queueQwkOutboundEchomail(int $messageId, ?int $excludeQwkMailboxId = null): void + { + $stmt = $this->db->prepare(" + SELECT em.echoarea_id, ea.is_local + FROM echomail em + JOIN echoareas ea ON em.echoarea_id = ea.id + WHERE em.id = ? + "); + $stmt->execute([$messageId]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $echoareaId = (int)($row['echoarea_id'] ?? 0); + if ($echoareaId <= 0) { + return; + } + + if (!empty($row['is_local'])) { + return; + } + + $subscriptions = (new \BinktermPHP\Qwk\QwkSubscriptionManager($this->db))->getSubscriptionsForArea((int)$echoareaId); + if ($subscriptions === []) { + return; + } + + $insert = $this->db->prepare(" + INSERT INTO qwk_outbound_messages (echomail_id, mailbox_id, queued_at) + VALUES (?, ?, NOW()) + ON CONFLICT (echomail_id, mailbox_id) DO NOTHING + "); + + foreach ($subscriptions as $subscription) { + $mailboxId = (int)($subscription['mailbox_id'] ?? 0); + if ($excludeQwkMailboxId !== null && $mailboxId === $excludeQwkMailboxId) { + continue; + } + $insert->execute([$messageId, $mailboxId]); + } + } + + /** + * Local-only areas must never propagate externally. Non-local areas only + * emit FTN packets when their domain is backed by an FTN network type. + * + * @param array $echoarea + */ + private function isFtnRoutableEchoarea(array $echoarea, ?string $domain): bool + { + if (!empty($echoarea['is_local'])) { + return false; + } + + $domain = strtolower(trim((string)$domain)); + if ($domain === '') { + return false; + } + + $network = (new \BinktermPHP\NetworkManager($this->db))->getByDomain($domain); + if ($network === null) { + // Legacy installations may have echo areas with a domain but no + // matching networks row yet. Preserve historical FTN behavior. + return true; + } + + return (int)($network['network_type'] ?? 0) === \BinktermPHP\NetworkManager::NETWORK_TYPE_FIDONET; + } + /** Returns an active uplink address for a given echoarea tag and domain. First choice is uplink in echoarea table, then to binkp.json configuration. * @param $echoareaTag - the tag, eg: LOCALTEST * @param $domain - the domain, eg: fidonet diff --git a/src/NetworkManager.php b/src/NetworkManager.php index 86ce2bff2..f9c2acd29 100644 --- a/src/NetworkManager.php +++ b/src/NetworkManager.php @@ -7,6 +7,7 @@ class NetworkManager { public const NETWORK_TYPE_FIDONET = 1; + public const NETWORK_TYPE_QWK = 2; private PDO $db; @@ -219,11 +220,12 @@ private function normalizeSettings(array $data): array { $charset = trim((string)($data['default_charset'] ?? '')); $policy = strtolower(trim((string)($data['posting_name_policy'] ?? 'real_name'))); + $networkType = $this->normalizeNetworkType($data['network_type'] ?? self::NETWORK_TYPE_FIDONET); return [ 'description' => trim((string)($data['description'] ?? '')) ?: null, 'website' => trim((string)($data['website'] ?? '')) ?: null, - 'network_type' => $this->normalizeNetworkType($data['network_type'] ?? self::NETWORK_TYPE_FIDONET), + 'network_type' => $networkType, 'allow_markup' => filter_var($data['allow_markup'] ?? false, FILTER_VALIDATE_BOOLEAN), 'allow_media' => filter_var($data['allow_media'] ?? false, FILTER_VALIDATE_BOOLEAN), 'default_charset' => $charset !== '' ? \BinktermPHP\Binkp\Config\BinkpConfig::normalizeCharset($charset) : null, @@ -234,7 +236,7 @@ private function normalizeSettings(array $data): array private function normalizeNetworkType(mixed $value): int { $type = (int)$value; - return $type === self::NETWORK_TYPE_FIDONET ? $type : self::NETWORK_TYPE_FIDONET; + return in_array($type, [self::NETWORK_TYPE_FIDONET, self::NETWORK_TYPE_QWK], true) ? $type : self::NETWORK_TYPE_FIDONET; } /** diff --git a/src/Qwk/QwkBuilder.php b/src/Qwk/QwkBuilder.php index b8d8e9899..f6ee90011 100644 --- a/src/Qwk/QwkBuilder.php +++ b/src/Qwk/QwkBuilder.php @@ -45,6 +45,8 @@ * * The conference map is persisted to qwk_download_log so that RepProcessor * can reverse-map conference numbers when a REP packet is later uploaded. + * + * Used by: Offline Reader */ class QwkBuilder { diff --git a/src/Qwk/QwkConferenceNumberManager.php b/src/Qwk/QwkConferenceNumberManager.php index 2ea76d1f6..6508c784e 100644 --- a/src/Qwk/QwkConferenceNumberManager.php +++ b/src/Qwk/QwkConferenceNumberManager.php @@ -11,6 +11,8 @@ * Conference 0 is reserved for personal mail / netmail. Echo areas use the * qwk_conference_number stored on the echoareas table and new areas are * assigned the next available number the first time they are needed. + * + * Used by: Offline Reader */ class QwkConferenceNumberManager { diff --git a/src/Qwk/QwkHttpController.php b/src/Qwk/QwkHttpController.php index 0de4b45ec..48b5a1db9 100644 --- a/src/Qwk/QwkHttpController.php +++ b/src/Qwk/QwkHttpController.php @@ -8,6 +8,11 @@ /** * Shared QWK HTTP helpers used by both session-authenticated API routes and * HTTP Basic Auth endpoints. + * + * This controller supports the local user-facing offline reader workflow: + * QWK download generation and REP upload/import on this same BBS. + * + * Used by: Offline Reader */ class QwkHttpController { diff --git a/src/Qwk/QwkInbound.php b/src/Qwk/QwkInbound.php new file mode 100644 index 000000000..70c8dc8f1 --- /dev/null +++ b/src/Qwk/QwkInbound.php @@ -0,0 +1,297 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->parser = $parser ?? new QwkPacketParser(); + $this->subscriptions = $subscriptions ?? new QwkSubscriptionManager($this->db); + $this->messageHandler = $messageHandler ?? new MessageHandler(); + $this->mailboxes = $mailboxes ?? new QwkMailboxManager($this->db); + } + + public function setLogger(?callable $logger): void + { + $this->logger = $logger; + $this->parser->setLogger($logger); + } + + /** + * @return array{imported:int,skipped:int} + */ + public function importPacket(int $mailboxId, string $zipPath): array + { + $parsed = $this->parser->parsePacket($zipPath); + $imported = 0; + $skipped = 0; + $mailbox = $this->mailboxes->getById($mailboxId, true); + $conferenceMap = is_array($parsed['control']['conferences'] ?? null) + ? $parsed['control']['conferences'] + : []; + + $this->log('INFO', sprintf( + 'Importing %d QWK message(s) for mailbox %d (%s)', + count($parsed['messages']), + $mailboxId, + (string)($mailbox['name'] ?? $mailbox['bbs_id'] ?? 'unknown') + )); + + foreach ($parsed['messages'] as $message) { + $messageLabel = $this->describeMessage($message); + $this->log('DEBUG', 'Processing ' . $messageLabel); + + if ($message->conferenceNumber <= 0) { + $skipped++; + $this->log('DEBUG', $messageLabel . ' skipped: conference number <= 0'); + continue; + } + + $conferenceTag = trim((string)($conferenceMap[$message->conferenceNumber] ?? '')); + $this->log('DEBUG', sprintf( + '%s mapped to conference tag "%s"', + $messageLabel, + $conferenceTag !== '' ? $conferenceTag : '(blank)' + )); + $subscription = $this->subscriptions->getOrCreateSubscriptionForConference( + $mailboxId, + $message->conferenceNumber, + $conferenceTag + ); + if ($subscription === null) { + $skipped++; + $this->log('WARNING', sprintf( + '%s skipped: no QWK subscription/placeholder area could be resolved for conference %d ("%s")', + $messageLabel, + $message->conferenceNumber, + $conferenceTag !== '' ? $conferenceTag : '(blank)' + )); + continue; + } + + $this->log('DEBUG', sprintf( + '%s resolved to echoarea_id=%d tag="%s" domain="%s"', + $messageLabel, + (int)($subscription['echoarea_id'] ?? 0), + (string)($subscription['tag'] ?? $subscription['conference_tag'] ?? ''), + (string)($subscription['domain'] ?? '') + )); + + if ($this->messageExists($mailboxId, $message->conferenceNumber, $message->messageNumber)) { + $skipped++; + $this->log('DEBUG', $messageLabel . ' skipped: duplicate QWK message already imported'); + continue; + } + + $replyToId = $this->findReplyToId($mailboxId, $message->conferenceNumber, $message->replyToNumber); + $sourceMsgId = $message->sourceMsgId ?: sprintf('qwk:%d:%d:%d', $mailboxId, $message->conferenceNumber, $message->messageNumber); + $fromAddress = $this->resolveFromAddress($message, $mailboxId, $mailbox); + + $this->log('DEBUG', sprintf( + '%s import context: reply_to_id=%s source_msgid="%s" from_address="%s" body_len=%d', + $messageLabel, + $replyToId !== null ? (string)$replyToId : 'null', + $sourceMsgId, + $fromAddress, + strlen($message->body) + )); + + $newId = $this->messageHandler->importExternalEchomail([ + 'echoarea_id' => (int)$subscription['echoarea_id'], + 'from_name' => $message->fromName, + 'to_name' => $message->toName !== '' ? $message->toName : 'All', + 'subject' => $message->subject !== '' ? $message->subject : '(no subject)', + 'message_text' => $message->body, + 'from_address' => $fromAddress, + 'reply_to_id' => $replyToId, + 'source_msgid' => $sourceMsgId, + 'qwk_mailbox_id' => $mailboxId, + 'qwk_conference_number' => $message->conferenceNumber, + 'qwk_msg_number' => $message->messageNumber, + 'origin_type' => \BinktermPHP\Echomail\RelayPolicyManager::TRANSPORT_QWK, + 'use_relay_policy' => true, + 'exclude_qwk_mailbox_id' => $mailboxId, + 'apply_gates' => true, + ]); + + if ($newId > 0) { + $imported++; + $this->log('INFO', $messageLabel . ' imported as echomail #' . $newId); + } else { + $skipped++; + $this->log('WARNING', $messageLabel . ' skipped: importExternalEchomail returned 0'); + } + } + + $this->log('INFO', sprintf( + 'QWK import summary for mailbox %d: %d imported, %d skipped', + $mailboxId, + $imported, + $skipped + )); + + return ['imported' => $imported, 'skipped' => $skipped]; + } + + /** + * @param array|null $mailbox + */ + private function buildSyntheticFromAddress(int $mailboxId, ?array $mailbox): string + { + $bbsId = strtoupper(trim((string)($mailbox['bbs_id'] ?? ''))); + if ($bbsId !== '') { + return substr('qwk:' . $bbsId, 0, 50); + } + + return substr('qwk:mailbox-' . $mailboxId, 0, 50); + } + + /** + * @param array|null $mailbox + */ + private function resolveFromAddress(QwkMessage $message, int $mailboxId, ?array $mailbox): string + { + $kludgeAddress = $this->extractAddressFromKludges($message->kludgeLines); + if ($kludgeAddress !== null) { + return $kludgeAddress; + } + + return $this->buildSyntheticFromAddress($mailboxId, $mailbox); + } + + private function extractAddressFromKludges(string $kludgeLines): ?string + { + $replyAddr = $this->extractFtnAddress('/^\x01REPLYADDR\s+(.+)$/im', $kludgeLines); + if ($replyAddr !== null) { + return $replyAddr; + } + + $msgIdAddress = $this->extractAddressFromMsgId($this->extractKludgeValue('MSGID', $kludgeLines)); + if ($msgIdAddress !== null) { + return $msgIdAddress; + } + + $replyTo = $this->extractFtnAddress('/^\x01REPLYTO\s+(.+)$/im', $kludgeLines); + if ($replyTo !== null) { + return $replyTo; + } + + return null; + } + + private function extractKludgeValue(string $name, string $kludgeLines): ?string + { + $pattern = '/^\x01' . preg_quote($name, '/') . ':\s*(.+)$/im'; + if (preg_match($pattern, $kludgeLines, $matches)) { + return trim($matches[1]); + } + + return null; + } + + private function extractFtnAddress(string $pattern, string $text): ?string + { + if (!preg_match($pattern, $text, $matches)) { + return null; + } + + $candidate = trim($matches[1]); + if (preg_match('/(\d+:\d+\/\d+(?:\.\d+)?(?:@[A-Za-z0-9_-]+)?)/', $candidate, $addressMatches)) { + return $addressMatches[1]; + } + + return null; + } + + private function extractAddressFromMsgId(?string $sourceMsgId): ?string + { + $sourceMsgId = trim((string)$sourceMsgId); + if ($sourceMsgId === '') { + return null; + } + + if (preg_match('/^(\d+:\d+\/\d+(?:\.\d+)?(?:@[A-Za-z0-9_-]+)?)(?:\s|$)/', $sourceMsgId, $matches)) { + return $matches[1]; + } + + return null; + } + + private function messageExists(int $mailboxId, int $conferenceNumber, int $messageNumber): bool + { + $stmt = $this->db->prepare(" + SELECT 1 + FROM echomail + WHERE qwk_mailbox_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? + LIMIT 1 + "); + $stmt->execute([$mailboxId, $conferenceNumber, $messageNumber]); + return (bool)$stmt->fetchColumn(); + } + + private function findReplyToId(int $mailboxId, int $conferenceNumber, int $messageNumber): ?int + { + if ($messageNumber <= 0) { + return null; + } + + $stmt = $this->db->prepare(" + SELECT id + FROM echomail + WHERE qwk_mailbox_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? + LIMIT 1 + "); + $stmt->execute([$mailboxId, $conferenceNumber, $messageNumber]); + $id = $stmt->fetchColumn(); + return $id ? (int)$id : null; + } + + private function describeMessage(QwkMessage $message): string + { + return sprintf( + 'QWK message #%d conf=%d reply=%d from="%s" to="%s" subject="%s"', + $message->messageNumber, + $message->conferenceNumber, + $message->replyToNumber, + $message->fromName, + $message->toName, + $message->subject + ); + } + + private function log(string $level, string $message): void + { + if ($this->logger !== null) { + ($this->logger)($level, $message); + } + } +} diff --git a/src/Qwk/QwkMailboxManager.php b/src/Qwk/QwkMailboxManager.php new file mode 100644 index 000000000..fa808e8f5 --- /dev/null +++ b/src/Qwk/QwkMailboxManager.php @@ -0,0 +1,201 @@ +db = $db ?? Database::getInstance()->getPdo(); + } + + /** + * @return array> + */ + public function getAll(bool $includeSecrets = false): array + { + $stmt = $this->db->query(" + SELECT id, name, bbs_id, host, port, username, password, ftp_remote_path, passive_mode, + poll_schedule, enabled, last_polled_at, last_error, created_at, updated_at + FROM qwk_mailboxes + ORDER BY LOWER(name), id + "); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($rows as &$row) { + $row = $this->normalizeRow($row); + if ($includeSecrets) { + $row['password_plain'] = $this->decryptPassword((string)($row['password'] ?? '')); + } else { + unset($row['password']); + } + } + unset($row); + return $rows; + } + + /** + * @param array $row + * @return array + */ + private function normalizeRow(array $row): array + { + $row['id'] = (int)($row['id'] ?? 0); + $row['port'] = (int)($row['port'] ?? 21); + $row['passive_mode'] = !array_key_exists('passive_mode', $row) + ? true + : filter_var($row['passive_mode'], FILTER_VALIDATE_BOOLEAN); + $row['enabled'] = filter_var($row['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + return $row; + } + + public function decryptPassword(string $encrypted): string + { + return SysK::decrypt($encrypted); + } + + /** + * @param array $data + */ + public function save(array $data, ?int $id = null): int + { + $name = trim((string)($data['name'] ?? '')); + $bbsId = strtoupper(substr(preg_replace('/[^A-Z0-9]/i', '', (string)($data['bbs_id'] ?? '')), 0, 8)); + $host = trim((string)($data['host'] ?? '')); + $port = (int)($data['port'] ?? 21); + $username = trim((string)($data['username'] ?? '')); + $password = (string)($data['password'] ?? ''); + $path = trim((string)($data['ftp_remote_path'] ?? '/')); + $passiveMode = !array_key_exists('passive_mode', $data) + ? true + : filter_var($data['passive_mode'], FILTER_VALIDATE_BOOLEAN); + $schedule = trim((string)($data['poll_schedule'] ?? '')); + $enabled = !empty($data['enabled']); + + if ($name === '' || $bbsId === '' || $host === '' || $username === '') { + throw new \InvalidArgumentException('Missing required mailbox fields'); + } + + if ($port < 1 || $port > 65535) { + throw new \InvalidArgumentException('Invalid port'); + } + + if ($id === null && $password === '') { + throw new \InvalidArgumentException('Password is required for new QWK mailboxes'); + } + + if ($id !== null && $password === '') { + $existing = $this->getById($id, true); + if (!$existing) { + throw new \InvalidArgumentException('QWK mailbox not found'); + } + $password = (string)($existing['password_plain'] ?? ''); + } + + $encryptedPassword = SysK::encrypt($password); + + if ($id === null) { + $stmt = $this->db->prepare(" + INSERT INTO qwk_mailboxes + (name, bbs_id, host, port, username, password, ftp_remote_path, passive_mode, poll_schedule, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + RETURNING id + "); + $stmt->execute([ + $name, + $bbsId, + $host, + $port, + $username, + $encryptedPassword, + $path !== '' ? $path : '/', + $passiveMode ? 'true' : 'false', + $schedule !== '' ? $schedule : null, + $enabled ? 'true' : 'false', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? (int)$row['id'] : 0; + } + + $stmt = $this->db->prepare(" + UPDATE qwk_mailboxes + SET name = ?, bbs_id = ?, host = ?, port = ?, username = ?, password = ?, + ftp_remote_path = ?, passive_mode = ?, poll_schedule = ?, enabled = ?, updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $name, + $bbsId, + $host, + $port, + $username, + $encryptedPassword, + $path !== '' ? $path : '/', + $passiveMode ? 'true' : 'false', + $schedule !== '' ? $schedule : null, + $enabled ? 'true' : 'false', + $id, + ]); + if ($stmt->rowCount() === 0) { + throw new \InvalidArgumentException('QWK mailbox not found'); + } + + return $id; + } + + public function getById(int $id, bool $includeSecret = false): ?array + { + $stmt = $this->db->prepare(" + SELECT id, name, bbs_id, host, port, username, password, ftp_remote_path, passive_mode, + poll_schedule, enabled, last_polled_at, last_error, created_at, updated_at + FROM qwk_mailboxes + WHERE id = ? + "); + $stmt->execute([$id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + + $row = $this->normalizeRow($row); + if ($includeSecret) { + $row['password_plain'] = $this->decryptPassword((string)($row['password'] ?? '')); + } else { + unset($row['password']); + } + + return $row; + } + + public function delete(int $id): bool + { + $stmt = $this->db->prepare("DELETE FROM qwk_mailboxes WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; + } + + public function markPollResult(int $id, ?string $error = null): void + { + $stmt = $this->db->prepare(" + UPDATE qwk_mailboxes + SET last_polled_at = NOW(), + last_error = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([$error, $id]); + } +} diff --git a/src/Qwk/QwkMessage.php b/src/Qwk/QwkMessage.php new file mode 100644 index 000000000..5b350c4ec --- /dev/null +++ b/src/Qwk/QwkMessage.php @@ -0,0 +1,49 @@ +messageNumber = $messageNumber; + $this->conferenceNumber = $conferenceNumber; + $this->replyToNumber = $replyToNumber; + $this->status = $status; + $this->toName = $toName; + $this->fromName = $fromName; + $this->subject = $subject; + $this->body = $body; + $this->kludgeLines = $kludgeLines; + $this->sourceMsgId = $sourceMsgId; + } +} diff --git a/src/Qwk/QwkOutbound.php b/src/Qwk/QwkOutbound.php new file mode 100644 index 000000000..f8b52bcac --- /dev/null +++ b/src/Qwk/QwkOutbound.php @@ -0,0 +1,125 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->builder = $builder ?? new RepPacketBuilder(); + } + + public function setLogger(?callable $logger): void + { + $this->logger = $logger; + } + + public function buildPendingRepPacket(array $mailbox): ?string + { + $rows = $this->getPendingMessages((int)$mailbox['id']); + if ($rows === []) { + return null; + } + + $messages = []; + foreach ($rows as $row) { + $replyToNum = 0; + if (!empty($row['reply_qwk_mailbox_id']) + && (int)$row['reply_qwk_mailbox_id'] === (int)$mailbox['id'] + && (int)$row['reply_qwk_conference_number'] === (int)$row['conference_number'] + ) { + $replyToNum = (int)$row['reply_qwk_msg_number']; + } + + $this->log('DEBUG', sprintf( + 'Queueing REP message queue_id=%d conf=%d reply=%d subject="%s"', + (int)$row['queue_id'], + (int)$row['conference_number'], + $replyToNum, + substr((string)($row['subject'] ?? ''), 0, 80) + )); + + $messages[] = [ + 'conference_number' => (int)$row['conference_number'], + 'to_name' => $row['to_name'] ?: 'All', + 'from_name' => $row['from_name'] ?: 'Unknown', + 'subject' => $row['subject'] ?: '(no subject)', + 'body' => $row['message_text'] ?: '', + 'reply_to_num' => $replyToNum, + 'from_address' => $row['from_address'] ?? null, + 'message_id' => $row['message_id'] ?? null, + 'reply_message_id' => $row['reply_message_id'] ?? null, + ]; + } + + return $this->builder->build((string)$mailbox['bbs_id'], $messages); + } + + public function markUploaded(int $mailboxId): void + { + $stmt = $this->db->prepare(" + UPDATE qwk_outbound_messages + SET sent_at = NOW() + WHERE mailbox_id = ? AND sent_at IS NULL + "); + $stmt->execute([$mailboxId]); + } + + /** + * @return array> + */ + private function getPendingMessages(int $mailboxId): array + { + $stmt = $this->db->prepare(" + SELECT qom.id AS queue_id, + em.id AS echomail_id, + em.to_name, + em.from_name, + em.from_address, + em.subject, + em.message_text, + em.message_id, + s.conference_number, + parent.qwk_mailbox_id AS reply_qwk_mailbox_id, + parent.qwk_conference_number AS reply_qwk_conference_number, + parent.qwk_msg_number AS reply_qwk_msg_number, + parent.message_id AS reply_message_id + FROM qwk_outbound_messages qom + JOIN echomail em ON em.id = qom.echomail_id + JOIN echo_area_qwk_subscriptions s + ON s.echoarea_id = em.echoarea_id + AND s.mailbox_id = qom.mailbox_id + LEFT JOIN echomail parent ON parent.id = em.reply_to_id + WHERE qom.mailbox_id = ? + AND qom.sent_at IS NULL + ORDER BY qom.id ASC + "); + $stmt->execute([$mailboxId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function log(string $level, string $message): void + { + if ($this->logger !== null) { + ($this->logger)($level, $message); + } + } +} diff --git a/src/Qwk/QwkPacketParser.php b/src/Qwk/QwkPacketParser.php new file mode 100644 index 000000000..25b8b1de4 --- /dev/null +++ b/src/Qwk/QwkPacketParser.php @@ -0,0 +1,273 @@ +logger = $logger; + } + + /** + * @return array{control:array,messages:array} + */ + public function parsePacket(string $zipPath): array + { + $zip = new ZipArchive(); + if ($zip->open($zipPath) !== true) { + throw new \RuntimeException('Failed to open QWK packet'); + } + + $messagesDat = $zip->getFromName('MESSAGES.DAT'); + $controlDat = $zip->getFromName('CONTROL.DAT'); + $zip->close(); + + if ($messagesDat === false) { + throw new \RuntimeException('MESSAGES.DAT not found in QWK packet'); + } + + $this->log('DEBUG', sprintf( + 'Parsing QWK packet %s (CONTROL.DAT=%s, MESSAGES.DAT=%d bytes)', + $zipPath, + $controlDat !== false ? 'present' : 'missing', + strlen($messagesDat) + )); + + return [ + 'control' => $this->parseControlDat($controlDat !== false ? $controlDat : ''), + 'messages' => $this->parseMessagesDat($messagesDat), + ]; + } + + /** + * @return array + */ + private function parseControlDat(string $contents): array + { + $lines = preg_split('/\r\n|\r|\n/', $contents) ?: []; + $conferenceMap = []; + $highest = isset($lines[10]) ? (int)trim((string)$lines[10]) : 0; + $offset = 11; + for ($i = 0; $i <= $highest; $i++) { + $number = isset($lines[$offset]) ? (int)trim((string)$lines[$offset]) : null; + $name = isset($lines[$offset + 1]) ? trim((string)$lines[$offset + 1]) : ''; + if ($number !== null) { + $conferenceMap[$number] = $name; + } + $offset += 2; + } + + $bbsIdField = (string)($lines[4] ?? '0,'); + $bbsIdParts = explode(',', $bbsIdField); + $bbsId = strtoupper(trim((string)($bbsIdParts[1] ?? ''))); + + $this->log('DEBUG', sprintf( + 'Parsed CONTROL.DAT: bbs_name="%s", bbs_id="%s", conferences=%d', + trim((string)($lines[0] ?? '')), + $bbsId, + count($conferenceMap) + )); + + return [ + 'bbs_name' => trim((string)($lines[0] ?? '')), + 'bbs_id' => $bbsId, + 'conferences' => $conferenceMap, + ]; + } + + /** + * @return array + */ + private function parseMessagesDat(string $data): array + { + $len = strlen($data); + if ($len === 0 || $len % self::BLOCK_SIZE !== 0) { + throw new \RuntimeException('MESSAGES.DAT is malformed'); + } + + $this->log('DEBUG', sprintf( + 'Scanning MESSAGES.DAT: %d bytes, %d blocks', + $len, + (int)($len / self::BLOCK_SIZE) + )); + + $messages = []; + $offset = self::BLOCK_SIZE; + while ($offset < $len) { + $message = $this->parseMessage($data, $offset); + if ($message === null) { + break; + } + $messages[] = $message['message']; + $offset += $message['blocks'] * self::BLOCK_SIZE; + } + + $this->log('DEBUG', sprintf('Parsed %d message(s) from MESSAGES.DAT', count($messages))); + + return $messages; + } + + /** + * @return array{message:QwkMessage,blocks:int}|null + */ + private function parseMessage(string $data, int $offset): ?array + { + if ($offset + self::BLOCK_SIZE > strlen($data)) { + return null; + } + + $header = substr($data, $offset, self::BLOCK_SIZE); + $blockCount = (int)trim(substr($header, 116, 6)); + if ($blockCount <= 0) { + return null; + } + + $messageNumber = (int)trim(substr($header, 1, 7)); + $replyToNumber = (int)trim(substr($header, 108, 8)); + $conferenceNumber = ord($header[123]) | (ord($header[124]) << 8); + $toName = rtrim(substr($header, 21, 25), "\x00 "); + $fromName = rtrim(substr($header, 46, 25), "\x00 "); + $subject = rtrim(substr($header, 71, 25), "\x00 "); + + $bodyLen = max(0, ($blockCount - 1) * self::BLOCK_SIZE); + $bodyRaw = substr($data, $offset + self::BLOCK_SIZE, $bodyLen); + $bodyRaw = rtrim($bodyRaw, "\x00"); + $bodyText = str_replace(self::LINE_TERM, "\n", $bodyRaw); + [$kludges, $bodyText] = $this->splitQwkeBody($bodyText); + [$headers, $bodyText] = $this->extractQwkePlaintextHeaders($bodyText); + $charset = $this->detectCharset($kludges); + + $this->log('DEBUG', sprintf( + 'Parsed QWK message #%d conf=%d reply=%d blocks=%d status=%s from="%s" to="%s" subject="%s" body_len=%d charset=%s kludges=%d', + $messageNumber, + $conferenceNumber, + $replyToNumber, + $blockCount, + $header[0], + trim((string)($headers['from'] ?? $fromName)), + trim((string)($headers['to'] ?? $toName)), + trim((string)($headers['subject'] ?? $subject)), + strlen($bodyText), + $charset ?? 'auto', + $kludges === '' ? 0 : count(explode("\n", $kludges)) + )); + + $message = new QwkMessage( + $messageNumber, + $conferenceNumber, + $replyToNumber, + $header[0], + $this->normaliseEncoding(trim((string)($headers['to'] ?? $toName)), $charset), + $this->normaliseEncoding(trim((string)($headers['from'] ?? $fromName)), $charset), + $this->normaliseEncoding(trim((string)($headers['subject'] ?? $subject)), $charset), + rtrim($this->normaliseEncoding($bodyText, $charset)), + $kludges, + $this->extractMsgId($kludges) + ); + + return ['message' => $message, 'blocks' => $blockCount]; + } + + private function log(string $level, string $message): void + { + if ($this->logger !== null) { + ($this->logger)($level, $message); + } + } + + /** + * @return array{0:string,1:string} + */ + private function splitQwkeBody(string $body): array + { + $lines = explode("\n", $body); + $kludges = []; + $bodyLines = []; + $inKludges = true; + foreach ($lines as $line) { + if ($inKludges && strlen($line) > 0 && ord($line[0]) === 0x01) { + $kludges[] = $line; + continue; + } + + $inKludges = false; + $bodyLines[] = $line; + } + + return [implode("\n", $kludges), implode("\n", $bodyLines)]; + } + + /** + * @return array{0:array,1:string} + */ + private function extractQwkePlaintextHeaders(string $body): array + { + $lines = explode("\n", $body); + $headers = []; + $i = 0; + while ($i < count($lines) && preg_match('/^(Subject|To|From):\s*(.*)/i', $lines[$i], $m)) { + $headers[strtolower($m[1])] = rtrim($m[2]); + $i++; + } + + if ($headers !== [] && isset($lines[$i]) && trim($lines[$i]) === '') { + $i++; + } + + return [$headers, implode("\n", array_slice($lines, $i))]; + } + + private function detectCharset(string $kludges): ?string + { + if (preg_match('/\x01CHRS:\s+(\S+)/i', $kludges, $m)) { + return strtoupper(trim($m[1])); + } + return null; + } + + private function extractMsgId(string $kludges): ?string + { + if (preg_match('/\x01MSGID:\s*(.+)$/im', $kludges, $m)) { + return trim($m[1]); + } + return null; + } + + private function normaliseEncoding(string $text, ?string $charset): string + { + if ($text === '') { + return ''; + } + + if ($charset === 'UTF-8' || ($charset === null && mb_check_encoding($text, 'UTF-8'))) { + return $text; + } + + $from = match (strtoupper((string)$charset)) { + 'CP437', 'IBM437', 'PC-8' => 'CP437', + 'CP850', 'IBM850' => 'CP850', + 'ISO-8859-1', 'LATIN1' => 'ISO-8859-1', + default => 'CP437', + }; + + $converted = @iconv($from, 'UTF-8//TRANSLIT//IGNORE', $text); + return ($converted !== false && $converted !== '') ? $converted : $text; + } +} diff --git a/src/Qwk/QwkPoller.php b/src/Qwk/QwkPoller.php new file mode 100644 index 000000000..3c81def46 --- /dev/null +++ b/src/Qwk/QwkPoller.php @@ -0,0 +1,204 @@ +mailboxes = $mailboxes ?? new QwkMailboxManager(); + $this->inbound = $inbound ?? new QwkInbound(); + $this->outbound = $outbound ?? new QwkOutbound(); + $this->transport = $transport ?? new FtpTransport($this->mailboxes); + } + + public function setLogger(?callable $logger): void + { + $this->logger = $logger; + $this->inbound->setLogger($logger); + $this->outbound->setLogger($logger); + if (method_exists($this->transport, 'setLogger')) { + $this->transport->setLogger($logger); + } + } + + public function setPreserveDebugArtifacts(bool $preserveDebugArtifacts): void + { + $this->preserveDebugArtifacts = $preserveDebugArtifacts; + } + + public function setDryRun(bool $dryRun): void + { + $this->dryRun = $dryRun; + } + + /** + * @return array + */ + public function pollMailbox(int $mailboxId): array + { + $mailbox = $this->mailboxes->getById($mailboxId, true); + if ($mailbox === null) { + throw new \InvalidArgumentException('QWK mailbox not found'); + } + + $this->log('INFO', sprintf( + 'Polling mailbox %d (%s) via %s:%d', + $mailboxId, + (string)($mailbox['name'] ?? $mailbox['bbs_id'] ?? 'unknown'), + (string)($mailbox['host'] ?? ''), + (int)($mailbox['port'] ?? 21) + )); + + $downloadedPath = null; + $repPath = null; + try { + $stats = [ + 'imported' => 0, + 'skipped' => 0, + 'uploaded' => false, + 'dry_run' => $this->dryRun, + 'downloaded' => false, + 'rep_created' => false, + ]; + + $downloadedPath = $this->transport->downloadPacket($mailbox); + if ($downloadedPath !== null) { + $size = @filesize($downloadedPath); + $this->log('DEBUG', sprintf( + 'Downloaded QWK packet to %s%s', + $downloadedPath, + $size !== false ? sprintf(' (%d bytes)', $size) : '' + )); + $stats['downloaded'] = true; + $this->preservePacketArtifact($downloadedPath, $mailbox, 'download', 'qwk'); + $importStats = $this->inbound->importPacket($mailboxId, $downloadedPath); + $stats['imported'] = $importStats['imported']; + $stats['skipped'] = $importStats['skipped']; + $this->log('INFO', sprintf( + 'Imported QWK packet: %d imported, %d skipped', + $stats['imported'], + $stats['skipped'] + )); + } else { + $this->log('DEBUG', 'No QWK packet available for download'); + } + + $repPath = $this->outbound->buildPendingRepPacket($mailbox); + if ($repPath !== null) { + $repSize = @filesize($repPath); + $this->log('DEBUG', sprintf( + 'Built REP packet at %s%s', + $repPath, + $repSize !== false ? sprintf(' (%d bytes)', $repSize) : '' + )); + $stats['rep_created'] = true; + $this->preservePacketArtifact($repPath, $mailbox, 'upload', 'rep'); + if ($this->dryRun) { + $this->log('INFO', 'Dry run enabled: skipping REP upload and leaving outbound messages queued'); + } else { + $stats['uploaded'] = $this->transport->uploadPacket($mailbox, $repPath); + if ($stats['uploaded']) { + $this->outbound->markUploaded($mailboxId); + $this->log('INFO', 'Uploaded REP packet and marked outbound messages as sent'); + } else { + $this->log('WARNING', 'REP packet upload failed'); + } + } + } else { + $this->log('DEBUG', 'No pending outbound REP messages for this mailbox'); + } + + $this->mailboxes->markPollResult($mailboxId, null); + $this->log('INFO', 'Mailbox poll completed successfully'); + return array_merge(['success' => true], $stats); + } catch (\Throwable $e) { + $this->mailboxes->markPollResult($mailboxId, $e->getMessage()); + $this->log('ERROR', 'Mailbox poll failed: ' . $e->getMessage()); + return ['success' => false, 'error' => $e->getMessage()]; + } finally { + if ($downloadedPath !== null && is_file($downloadedPath)) { + @unlink($downloadedPath); + } + if ($repPath !== null && is_file($repPath)) { + @unlink($repPath); + } + } + } + + /** + * @return array> + */ + public function pollAllEnabled(): array + { + $results = []; + foreach ($this->mailboxes->getAll() as $mailbox) { + if (empty($mailbox['enabled'])) { + continue; + } + $results[(string)$mailbox['name']] = $this->pollMailbox((int)$mailbox['id']); + } + return $results; + } + + private function log(string $level, string $message): void + { + if ($this->logger !== null) { + ($this->logger)($level, $message); + } + } + + /** + * @param array $mailbox + */ + private function preservePacketArtifact(string $sourcePath, array $mailbox, string $direction, string $extension): void + { + if (!$this->preserveDebugArtifacts || !is_file($sourcePath)) { + return; + } + + $directory = getcwd() . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'qwk-debug'; + if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) { + $this->log('WARNING', 'Failed to create debug packet directory: ' . $directory); + return; + } + + $timestamp = (new \DateTimeImmutable('now'))->format('Y-m-d_H-i-s'); + $mailboxLabel = (string)($mailbox['name'] ?? $mailbox['bbs_id'] ?? $mailbox['id'] ?? 'mailbox'); + $mailboxLabel = preg_replace('/[^A-Za-z0-9_-]+/', '_', $mailboxLabel) ?: 'mailbox'; + $destination = $directory . DIRECTORY_SEPARATOR + . sprintf('%s_%s_%s.%s', $timestamp, $mailboxLabel, $direction, $extension); + + if (@copy($sourcePath, $destination)) { + $this->log('DEBUG', 'Saved debug packet copy to ' . $destination); + return; + } + + $this->log('WARNING', 'Failed to save debug packet copy to ' . $destination); + } +} diff --git a/src/Qwk/QwkSubscriptionManager.php b/src/Qwk/QwkSubscriptionManager.php new file mode 100644 index 000000000..3e2f8f833 --- /dev/null +++ b/src/Qwk/QwkSubscriptionManager.php @@ -0,0 +1,132 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->echoareaManager = $echoareaManager ?? new EchoareaManager($this->db); + } + + /** + * @return array> + */ + public function getSubscriptionsForArea(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT s.id, s.echoarea_id, s.mailbox_id, s.conference_tag, s.conference_number, s.auto_created, + m.name AS mailbox_name, m.bbs_id AS mailbox_bbs_id, m.enabled AS mailbox_enabled + FROM echo_area_qwk_subscriptions s + JOIN qwk_mailboxes m ON m.id = s.mailbox_id + WHERE s.echoarea_id = ? + ORDER BY LOWER(m.name), s.conference_number + "); + $stmt->execute([$echoareaId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** + * @return array> + */ + public function getSubscriptionsForMailbox(int $mailboxId): array + { + $stmt = $this->db->prepare(" + SELECT s.*, e.tag, e.domain, e.is_local, e.uplink_address + FROM echo_area_qwk_subscriptions s + JOIN echoareas e ON e.id = s.echoarea_id + WHERE s.mailbox_id = ? + ORDER BY s.conference_number, e.id + "); + $stmt->execute([$mailboxId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + public function getSubscriptionForConference(int $mailboxId, int $conferenceNumber): ?array + { + $stmt = $this->db->prepare(" + SELECT s.*, e.tag, e.domain, e.is_local, e.uplink_address + FROM echo_area_qwk_subscriptions s + JOIN echoareas e ON e.id = s.echoarea_id + WHERE s.mailbox_id = ? AND s.conference_number = ? + LIMIT 1 + "); + $stmt->execute([$mailboxId, $conferenceNumber]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + public function getOrCreateSubscriptionForConference(int $mailboxId, int $conferenceNumber, string $conferenceTag): ?array + { + $existing = $this->getSubscriptionForConference($mailboxId, $conferenceNumber); + if ($existing !== null) { + return $existing; + } + + $echoareaId = $this->echoareaManager->createQwkPlaceholderArea($conferenceTag, $conferenceNumber); + if ($echoareaId <= 0) { + return null; + } + + $insertStmt = $this->db->prepare(" + INSERT INTO echo_area_qwk_subscriptions + (echoarea_id, mailbox_id, conference_tag, conference_number, auto_created, created_at) + VALUES (?, ?, ?, ?, 'true', NOW()) + ON CONFLICT (mailbox_id, conference_number) DO NOTHING + "); + $insertStmt->execute([ + $echoareaId, + $mailboxId, + trim($conferenceTag) !== '' ? trim($conferenceTag) : ('Conference ' . $conferenceNumber), + $conferenceNumber, + ]); + + return $this->getSubscriptionForConference($mailboxId, $conferenceNumber); + } + + /** + * @param array> $subscriptions + */ + public function replaceAreaSubscriptions(int $echoareaId, array $subscriptions): void + { + $deleteStmt = $this->db->prepare("DELETE FROM echo_area_qwk_subscriptions WHERE echoarea_id = ?"); + $deleteStmt->execute([$echoareaId]); + + if ($subscriptions === []) { + return; + } + + $insertStmt = $this->db->prepare(" + INSERT INTO echo_area_qwk_subscriptions + (echoarea_id, mailbox_id, conference_tag, conference_number, auto_created, created_at) + VALUES (?, ?, ?, ?, 'false', NOW()) + "); + + foreach ($subscriptions as $subscription) { + $mailboxId = (int)($subscription['mailbox_id'] ?? 0); + $conferenceNumber = (int)($subscription['conference_number'] ?? 0); + $conferenceTag = trim((string)($subscription['conference_tag'] ?? '')); + if ($mailboxId <= 0 || $conferenceNumber < 0 || $conferenceTag === '') { + throw new \InvalidArgumentException('Invalid QWK subscription payload'); + } + + $insertStmt->execute([$echoareaId, $mailboxId, $conferenceTag, $conferenceNumber]); + } + } +} diff --git a/src/Qwk/RepPacketBuilder.php b/src/Qwk/RepPacketBuilder.php new file mode 100644 index 000000000..775352547 --- /dev/null +++ b/src/Qwk/RepPacketBuilder.php @@ -0,0 +1,156 @@ +> $messages + */ + public function build(string $bbsId, array $messages): string + { + $normalizedBbsId = strtoupper(substr(preg_replace('/[^A-Z0-9]/i', '', $bbsId), 0, 8)); + if ($normalizedBbsId === '') { + throw new \InvalidArgumentException('REP packet BBS ID is required'); + } + + // REP block 0 must begin with the destination BBSID and be space-padded. + $msgData = str_pad($normalizedBbsId, self::BLOCK_SIZE, ' '); + $headersDat = []; + $logical = 1; + $conferenceLogicalNumbers = []; + foreach ($messages as $message) { + $confNumber = (int)($message['conference_number'] ?? 0); + $conferenceLogicalNumbers[$confNumber] = ($conferenceLogicalNumbers[$confNumber] ?? 0) + 1; + $headerOffset = strlen($msgData); + $headersDat[$headerOffset] = $this->buildHeadersDatSection($message, $confNumber); + $msgData .= $this->encodeMessage($message, $logical, $conferenceLogicalNumbers[$confNumber]); + $logical++; + } + + $zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $normalizedBbsId . '_' . bin2hex(random_bytes(8)) . '.rep'; + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Failed to create REP archive'); + } + $zip->addFromString($normalizedBbsId . '.MSG', $msgData); + if ($headersDat !== []) { + $zip->addFromString('HEADERS.DAT', $this->buildHeadersDat($headersDat)); + } + $zip->close(); + + return $zipPath; + } + + /** + * @param array $message + */ + private function encodeMessage(array $message, int $logicalNumber, int $conferenceLogicalNumber): string + { + $body = str_replace(["\r\n", "\r", "\n"], self::LINE_TERM, rtrim((string)$message['body'])) . self::LINE_TERM; + $cp437 = @iconv('UTF-8', self::BODY_CHARSET . '//TRANSLIT//IGNORE', $body); + $body = ($cp437 !== false && $cp437 !== '') ? $cp437 : $body; + + $bodyBlockCount = max(1, (int)ceil(strlen($body) / self::BLOCK_SIZE)); + $totalBlocks = $bodyBlockCount + 1; + $date = new \DateTime('now', new \DateTimeZone('UTC')); + $confNumber = (int)$message['conference_number']; + $status = $confNumber === 0 ? '+' : ' '; + + // REP uses the same 128-byte header layout as QWK MESSAGES.DAT: + // 8-byte password, 8-byte reply reference, 6-byte block count, + // then activity/conf/logical-in-conf/net-tag bytes. + $header = $status; + // Synchronet parses the 7-byte ASCII conference field with atol(), + // so the number must be left-justified with trailing spaces. + $header .= str_pad((string)$confNumber, 7, ' ', STR_PAD_RIGHT); + $header .= str_pad($date->format('m-d-y'), 8, "\x00"); + $header .= str_pad($date->format('H:i'), 5, "\x00"); + $header .= str_pad(substr((string)$message['to_name'], 0, 25), 25, "\x00"); + $header .= str_pad(substr((string)$message['from_name'], 0, 25), 25, "\x00"); + $header .= str_pad(substr((string)$message['subject'], 0, 25), 25, "\x00"); + $header .= str_pad('', 8, "\x00"); + $header .= str_pad((string)($message['reply_to_num'] ?? 0), 8, ' ', STR_PAD_LEFT); + $header .= str_pad((string)$totalBlocks, 6, ' ', STR_PAD_LEFT); + $header .= chr(0xE1); + $header .= pack('v', $confNumber); + $header .= pack('v', $conferenceLogicalNumber); + $header .= "\x00"; + $header .= "\x00\x00\x00\x00"; + + if (strlen($header) !== self::BLOCK_SIZE) { + throw new \LogicException('REP header block is ' . strlen($header) . ' bytes, expected 128.'); + } + + $paddedBody = str_pad($body, $bodyBlockCount * self::BLOCK_SIZE, "\x00"); + return $header . $paddedBody; + } + + /** + * @param array $message + */ + private function buildHeadersDatSection(array $message, int $conferenceNumber): string + { + $lines = []; + $lines[] = 'Conference: ' . $conferenceNumber; + $lines[] = 'Sender: ' . $this->encodeHeaderValue((string)($message['from_name'] ?? 'Unknown')); + $lines[] = 'To: ' . $this->encodeHeaderValue((string)($message['to_name'] ?? 'All')); + $lines[] = 'Subject: ' . $this->encodeHeaderValue((string)($message['subject'] ?? '(no subject)')); + + $fromAddress = trim((string)($message['from_address'] ?? '')); + if ($fromAddress !== '') { + $lines[] = 'SenderNetAddr: ' . $this->encodeHeaderValue($fromAddress); + } + + $messageId = trim((string)($message['message_id'] ?? '')); + if ($messageId !== '') { + $lines[] = 'X-FTN-MSGID: ' . $this->encodeHeaderValue($messageId); + } + + $replyMessageId = trim((string)($message['reply_message_id'] ?? '')); + if ($replyMessageId !== '') { + $lines[] = 'X-FTN-REPLY: ' . $this->encodeHeaderValue($replyMessageId); + } + + $lines[] = 'X-FTN-CHRS: ' . self::BODY_CHARSET; + + return implode("\r\n", $lines); + } + + /** + * @param array $sections + */ + private function buildHeadersDat(array $sections): string + { + $contents = []; + foreach ($sections as $offset => $sectionBody) { + $contents[] = '[' . strtolower(dechex($offset)) . ']'; + $contents[] = $sectionBody; + $contents[] = ''; + } + + return implode("\r\n", $contents); + } + + private function encodeHeaderValue(string $value): string + { + $value = str_replace(["\r\n", "\r", "\n"], ' ', trim($value)); + $encoded = @iconv('UTF-8', self::BODY_CHARSET . '//TRANSLIT//IGNORE', $value); + return ($encoded !== false && $encoded !== '') ? $encoded : $value; + } +} diff --git a/src/Qwk/RepProcessor.php b/src/Qwk/RepProcessor.php index 6efdc55d3..c9910dafc 100644 --- a/src/Qwk/RepProcessor.php +++ b/src/Qwk/RepProcessor.php @@ -50,6 +50,8 @@ * - The BBSID in the uploaded MSG filename must match this installation. * - The MSG file size must be a non-zero multiple of 128 bytes. * - No ZIP entry path traversal: extraction targets a controlled temp dir. + * + * Used by: Offline Reader */ class RepProcessor { @@ -527,7 +529,7 @@ private function importNetmailReply(array $msg, string $subject, string $body, i $toName, $subject, $body, - null, // fromName — resolved by MessageHandler from user record + $this->resolveImportFromName($userId, $msg), $replyToId, // replyToId false, // crashmail null // tagline @@ -557,7 +559,13 @@ private function importEchomailReply(array $msg, array $conf, string $subject, s $subject, $body, $replyToId, // replyToId - null // tagline + null, // tagline + false, // skipCredits + null, // markupType + '', // prependKludges + null, // tearlineComponent + null, // charset + $this->resolveImportFromName($userId, $msg) ); return true; @@ -716,6 +724,24 @@ private function pruneImportedHashes(int $userId): void )->execute([$userId]); } + private function resolveImportFromName(int $userId, array $msg): ?string + { + $fromName = trim((string)($msg['from_name'] ?? '')); + if ($fromName === '') { + return null; + } + + $stmt = $this->db->prepare('SELECT is_bbs_account FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $value = $stmt->fetchColumn(); + + if (!filter_var($value, FILTER_VALIDATE_BOOLEAN)) { + return null; + } + + return $fromName; + } + // ------------------------------------------------------------------------- // Filesystem helpers // ------------------------------------------------------------------------- diff --git a/src/Qwk/Transport/FtpTransport.php b/src/Qwk/Transport/FtpTransport.php new file mode 100644 index 000000000..b8ab01abf --- /dev/null +++ b/src/Qwk/Transport/FtpTransport.php @@ -0,0 +1,227 @@ +.QWK` from a remote mailbox and uploads `.REP` + * back to that same mailbox using the configured FTP connection details. + * + * Used by: Inter-BBS + */ +class FtpTransport implements TransportInterface +{ + private QwkMailboxManager $mailboxManager; + /** @var callable|null */ + private $logger = null; + + public function __construct(?QwkMailboxManager $mailboxManager = null) + { + $this->mailboxManager = $mailboxManager ?? new QwkMailboxManager(); + } + + public function setLogger(?callable $logger): void + { + $this->logger = $logger; + } + + public function downloadPacket(array $mailbox): ?string + { + $conn = $this->connect($mailbox); + try { + $remotePath = $this->buildRemotePath($mailbox, strtoupper((string)$mailbox['bbs_id']) . '.QWK'); + $this->log('DEBUG', sprintf('FTP RETR %s', $remotePath)); + $tmpPath = tempnam(sys_get_temp_dir(), 'qwkdl_'); + if ($tmpPath === false) { + throw new \RuntimeException('Failed to allocate temporary download path'); + } + + $remoteSize = @ftp_size($conn, $remotePath); + if ($remoteSize > -1) { + $this->log('DEBUG', sprintf('FTP SIZE %s => %d bytes', $remotePath, $remoteSize)); + } else { + $this->log('DEBUG', sprintf('FTP SIZE %s => unavailable', $remotePath)); + } + + $status = @ftp_nb_get($conn, $tmpPath, $remotePath, FTP_BINARY); + if ($status === FTP_FAILED) { + @unlink($tmpPath); + if ($remoteSize === -1 && $this->isRemotePacketMissing($conn, $remotePath)) { + $this->log('DEBUG', sprintf('FTP RETR %s => no packet available', $remotePath)); + return null; + } + + $this->log('DEBUG', sprintf('FTP RETR %s => transfer failed before data was received', $remotePath)); + throw new \RuntimeException('Failed to download QWK packet from remote mailbox'); + } + + $this->logTransferProgress($conn, $status, $tmpPath, $remotePath, $remoteSize, false); + return $tmpPath; + } finally { + @ftp_close($conn); + } + } + + public function uploadPacket(array $mailbox, string $localPacketPath): bool + { + $conn = $this->connect($mailbox); + try { + $remotePath = $this->buildRemotePath($mailbox, strtoupper((string)$mailbox['bbs_id']) . '.REP'); + $localSize = @filesize($localPacketPath); + $this->log('DEBUG', sprintf( + 'FTP STOR %s%s', + $remotePath, + $localSize !== false ? sprintf(' (%d bytes)', $localSize) : '' + )); + + $status = @ftp_nb_put($conn, $remotePath, $localPacketPath, FTP_BINARY); + if ($status === FTP_FAILED) { + $this->log('DEBUG', sprintf('FTP STOR %s => failed to start transfer', $remotePath)); + return false; + } + + return $this->logTransferProgress($conn, $status, $localPacketPath, $remotePath, $localSize === false ? -1 : $localSize, true); + } finally { + @ftp_close($conn); + } + } + + /** + * @return resource + */ + private function connect(array $mailbox) + { + if (!function_exists('ftp_connect')) { + throw new \RuntimeException('PHP FTP extension is not available'); + } + + $host = (string)$mailbox['host']; + $port = (int)($mailbox['port'] ?? 21); + $this->log('DEBUG', sprintf('FTP CONNECT %s:%d', $host, $port)); + $conn = @ftp_connect($host, $port, 20); + if ($conn === false) { + throw new \RuntimeException('Failed to connect to FTP host'); + } + + $password = $this->mailboxManager->decryptPassword((string)($mailbox['password'] ?? '')); + $this->log('DEBUG', sprintf('FTP LOGIN %s', (string)$mailbox['username'])); + if (!@ftp_login($conn, (string)$mailbox['username'], $password)) { + @ftp_close($conn); + throw new \RuntimeException('FTP login failed'); + } + + $pwd = @ftp_pwd($conn); + if (is_string($pwd) && $pwd !== '') { + $this->log('DEBUG', sprintf('FTP PWD => %s', $pwd)); + } + + $passiveMode = !array_key_exists('passive_mode', $mailbox) + ? true + : filter_var($mailbox['passive_mode'], FILTER_VALIDATE_BOOLEAN); + @ftp_pasv($conn, $passiveMode); + $this->log('DEBUG', 'FTP PASV ' . ($passiveMode ? 'enabled' : 'disabled')); + return $conn; + } + + private function buildRemotePath(array $mailbox, string $filename): string + { + $base = trim((string)($mailbox['ftp_remote_path'] ?? '/')); + if ($base === '' || $base === '.') { + return $filename; + } + + return rtrim(str_replace('\\', '/', $base), '/') . '/' . $filename; + } + + /** + * @param resource $conn + */ + private function isRemotePacketMissing($conn, string $remotePath): bool + { + if (!function_exists('ftp_raw')) { + return false; + } + + $responses = @ftp_raw($conn, 'SIZE ' . $remotePath); + if (!is_array($responses) || $responses === []) { + return false; + } + + $firstLine = strtoupper(trim((string)$responses[0])); + if (str_starts_with($firstLine, '213 ')) { + return false; + } + + return str_starts_with($firstLine, '450 ') + || str_starts_with($firstLine, '550 '); + } + + /** + * @param resource $conn + */ + private function logTransferProgress($conn, int $status, string $localPath, string $remotePath, int $knownSize, bool $upload): bool + { + $lastBucket = -1; + while ($status === FTP_MOREDATA) { + clearstatcache(true, $localPath); + $currentSize = @filesize($localPath); + $bucket = $this->progressBucket($currentSize === false ? 0 : (int)$currentSize, $knownSize); + if ($bucket !== $lastBucket) { + $direction = $upload ? 'upload' : 'download'; + $this->log('DEBUG', sprintf( + 'FTP %s progress %s: %s', + $upload ? 'STOR' : 'RETR', + $remotePath, + $this->formatProgress($currentSize === false ? 0 : (int)$currentSize, $knownSize, $direction) + )); + $lastBucket = $bucket; + } + $status = @ftp_nb_continue($conn); + } + + if ($status === FTP_FINISHED) { + clearstatcache(true, $localPath); + $finalSize = @filesize($localPath); + $direction = $upload ? 'uploaded' : 'downloaded'; + $this->log('DEBUG', sprintf( + 'FTP %s %s complete: %s', + $upload ? 'STOR' : 'RETR', + $remotePath, + $this->formatProgress($finalSize === false ? 0 : (int)$finalSize, $knownSize, $direction) + )); + return true; + } + + $this->log('DEBUG', sprintf('FTP %s %s failed during transfer', $upload ? 'STOR' : 'RETR', $remotePath)); + return false; + } + + private function progressBucket(int $currentSize, int $knownSize): int + { + if ($knownSize > 0) { + return (int)floor(($currentSize / max(1, $knownSize)) * 10); + } + + return (int)floor($currentSize / (256 * 1024)); + } + + private function formatProgress(int $currentSize, int $knownSize, string $verb): string + { + if ($knownSize > 0) { + $percent = min(100, (int)floor(($currentSize / max(1, $knownSize)) * 100)); + return sprintf('%s %d/%d bytes (%d%%)', $verb, $currentSize, $knownSize, $percent); + } + + return sprintf('%s %d bytes', $verb, $currentSize); + } + + private function log(string $level, string $message): void + { + if ($this->logger !== null) { + ($this->logger)($level, $message); + } + } +} diff --git a/src/Qwk/Transport/TransportInterface.php b/src/Qwk/Transport/TransportInterface.php new file mode 100644 index 000000000..1ebd791d3 --- /dev/null +++ b/src/Qwk/Transport/TransportInterface.php @@ -0,0 +1,18 @@ +
+
+
+ + +
+
+ + +
+
@@ -57,6 +77,13 @@ +
+ + +
@@ -136,6 +163,7 @@ let networks = []; let networkModal; let domainModal; const NETWORK_TYPE_FIDONET = 1; +const NETWORK_TYPE_QWK = 2; function uiT(key, fallback, params = {}) { if (window.t) { @@ -147,6 +175,8 @@ function uiT(key, fallback, params = {}) { document.addEventListener('DOMContentLoaded', function() { networkModal = new bootstrap.Modal(document.getElementById('networkModal')); domainModal = new bootstrap.Modal(document.getElementById('domainModal')); + document.getElementById('networkTypeFilter').addEventListener('change', renderNetworks); + document.getElementById('networkSearch').addEventListener('input', renderNetworks); loadI18nNamespaces(['common', 'errors']).then(loadNetworks); }); @@ -166,7 +196,19 @@ function loadNetworks() { function renderNetworks() { const tbody = document.querySelector('#networksTable tbody'); tbody.innerHTML = ''; - networks.forEach(network => { + const filteredNetworks = getFilteredNetworks(); + if (filteredNetworks.length === 0) { + const row = document.createElement('tr'); + row.innerHTML = ` +
+ `; + tbody.appendChild(row); + return; + } + + filteredNetworks.forEach(network => { const flags = [ network.allow_markup ? uiT('ui.admin.networks.markup', 'Markup') : null, network.allow_media ? uiT('ui.admin.networks.media', 'Media') : null, @@ -204,6 +246,35 @@ function renderNetworks() { }); } +function getFilteredNetworks() { + const selectedType = document.getElementById('networkTypeFilter')?.value || ''; + const searchTerm = (document.getElementById('networkSearch')?.value || '').trim().toLowerCase(); + + return networks.filter(network => { + if (selectedType !== '' && String(Number(network.network_type || NETWORK_TYPE_FIDONET)) !== selectedType) { + return false; + } + + if (searchTerm === '') { + return true; + } + + const haystack = [ + network.domain, + network.name, + network.description, + network.website, + getNetworkTypeLabel(network), + ...(Array.isArray(network.uplinks) ? network.uplinks : []) + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(searchTerm); + }); +} + function renderNetworkUplinks(network) { const uplinks = Array.isArray(network.uplinks) ? network.uplinks : []; if (uplinks.length === 0) { @@ -220,10 +291,19 @@ function getNetworkTypeIconClass(network) { if (Number(network.network_type) === NETWORK_TYPE_FIDONET) { return 'fas fa-dog'; } + if (Number(network.network_type) === NETWORK_TYPE_QWK) { + return 'fas fa-envelope-open-text'; + } return 'fas fa-globe'; } function getNetworkTypeLabel(network) { + if (Number(network.network_type) === NETWORK_TYPE_QWK) { + return uiT('ui.admin.networks.type_qwk', 'QWK'); + } + if (Number(network.network_type) === NETWORK_TYPE_FIDONET) { + return uiT('ui.admin.networks.type_fidonet', 'FidoNet'); + } return network.name || network.domain || uiT('ui.admin.networks.type', 'Type'); } @@ -234,6 +314,8 @@ function openNetworkModal(id = null) { document.getElementById('networkDomain').value = network ? network.domain : ''; document.getElementById('networkDomain').readOnly = !!network; document.getElementById('networkChangeDomainBtn').classList.toggle('d-none', !network); + document.getElementById('networkType').value = String(network ? (network.network_type || NETWORK_TYPE_FIDONET) : NETWORK_TYPE_FIDONET); + document.getElementById('networkType').disabled = !!network; document.getElementById('networkName').value = network ? network.name : ''; document.getElementById('networkDescription').value = network ? (network.description || '') : ''; document.getElementById('networkWebsite').value = network ? (network.website || '') : ''; @@ -261,6 +343,7 @@ function saveNetwork() { name: document.getElementById('networkName').value.trim(), description: document.getElementById('networkDescription').value.trim(), website: document.getElementById('networkWebsite').value.trim(), + network_type: parseInt(document.getElementById('networkType').value, 10) || NETWORK_TYPE_FIDONET, posting_name_policy: document.getElementById('networkPostingNamePolicy').value, default_charset: document.getElementById('networkDefaultCharset').value, allow_markup: document.getElementById('networkAllowMarkup').checked, @@ -273,10 +356,10 @@ function saveNetwork() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) - .then(response => response.json()) - .then(data => { - if (!data.success) { - throw new Error(apiError(data, uiT('ui.admin.networks.save_failed', 'Failed to save network'))); + .then(readApiResponse) + .then(({ response, data, rawText }) => { + if (!response.ok || !data.success) { + throw new Error(apiError(data, rawText || uiT('ui.admin.networks.save_failed', 'Failed to save network'))); } networkModal.hide(); loadNetworks(); @@ -342,6 +425,16 @@ function formatDeleteNetworkError(payload) { } function apiError(payload, fallback) { + if (payload && payload.error) { + const genericNetworkErrorCodes = [ + 'errors.admin.networks.save_failed', + 'errors.admin.networks.change_domain_failed', + 'errors.admin.networks.delete_failed' + ]; + if (genericNetworkErrorCodes.includes(payload.error_code)) { + return String(payload.error); + } + } if (window.getApiErrorMessage) { return window.getApiErrorMessage(payload, fallback); } @@ -355,6 +448,38 @@ function apiMessage(payload, fallback) { return payload && payload.message ? String(payload.message) : fallback; } +function readApiResponse(response) { + return response.text().then(text => { + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (error) { + data = {}; + } + } + + return { + response, + data, + rawText: extractApiErrorText(text) + }; + }); +} + +function extractApiErrorText(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + return ''; + } + + if (trimmed.startsWith('<')) { + return ''; + } + + return trimmed; +} + function showAlert(id, message, type) { document.getElementById(id).innerHTML = ` +
+ + +
{{ t('ui.admin_users.is_bbs_account_help', {}, locale, ['common']) }}
+
@@ -214,7 +221,7 @@ @@ -546,7 +564,7 @@ function renderAllUsers(users) { - + @@ -727,6 +745,7 @@ function editUser(userId) { $('#edit-is-active').prop('checked', !!user.is_active); $('#edit-is-admin').prop('checked', !!user.is_admin); $('#edit-is-system').prop('checked', !!user.is_system); + $('#edit-is-bbs-account').prop('checked', !!user.is_bbs_account); $('#edit-echomail-moderation-forced').prop('checked', !!user.echomail_moderation_forced); $('#edit-credit-balance').val(user.credit_balance || 0); $('#edit-grant-credits-amount').val(''); @@ -762,6 +781,7 @@ function saveUserChanges() { is_active: $('#edit-is-active').is(':checked') ? 1 : 0, is_admin: $('#edit-is-admin').is(':checked') ? 1 : 0, is_system: $('#edit-is-system').is(':checked') ? 1 : 0, + is_bbs_account: $('#edit-is-bbs-account').is(':checked') ? 1 : 0, echomail_moderation_forced: $('#edit-echomail-moderation-forced').is(':checked') ? 1 : 0 }; @@ -893,6 +913,7 @@ function createNewUser() { $('#create-is-active').prop('checked', true); $('#create-is-admin').prop('checked', false); $('#create-is-system').prop('checked', false); + $('#create-is-bbs-account').prop('checked', false); // Show modal new bootstrap.Modal(document.getElementById('createUserModal')).show(); @@ -907,6 +928,7 @@ function saveNewUser() { const isActive = $('#create-is-active').is(':checked') ? 1 : 0; const isAdmin = $('#create-is-admin').is(':checked') ? 1 : 0; const isSystem = $('#create-is-system').is(':checked') ? 1 : 0; + const isBbsAccount = $('#create-is-bbs-account').is(':checked') ? 1 : 0; // Validate required fields if (!username || !realName || !password) { @@ -939,7 +961,8 @@ function saveNewUser() { password: password, is_active: isActive, is_admin: isAdmin, - is_system: isSystem + is_system: isSystem, + is_bbs_account: isBbsAccount }; const btn = $('button[onclick="saveNewUser()"]'); diff --git a/templates/echoareas.twig b/templates/echoareas.twig index 2a34f3b82..931ac1603 100644 --- a/templates/echoareas.twig +++ b/templates/echoareas.twig @@ -13,6 +13,10 @@ {{ t('ui.common.import', {}, locale, ['common']) }} + +
+ + +
{{ t('ui.echoareas.tag_help', {}, locale, ['common']) }}
- -
{{ t('ui.echoareas.description_help', {}, locale, ['common']) }}
- -
-
- - -
{{ t('ui.echoareas.uplink_address_help', {}, locale, ['common']) }}
+
+
+ + +
+ +
{{ t('ui.echoareas.description_help', {}, locale, ['common']) }}
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
+ +
+ + +
{{ t('ui.echoareas.network_domain_help', {}, locale, ['common']) }}
+
+ +
+
+ + +
{{ t('ui.echoareas.uplink_address_help', {}, locale, ['common']) }}
+
+
+ + +
{{ t('ui.echoareas.moderator_help', {}, locale, ['common']) }}
-
{{ t('ui.echoareas.selected_color', {}, locale, ['common']) }}
-
-
- - -
{{ t('ui.echoareas.network_domain_help', {}, locale, ['common']) }}
-
+
+ +
+
+ + +
{{ t('ui.echoareas.posting_name_policy_help', {}, locale, ['common']) }}
+
+
+ + +
{{ t('ui.echoareas.art_format_hint_help', {}, locale, ['common']) }}
+
+
-
- - -
{{ t('ui.echoareas.moderator_help', {}, locale, ['common']) }}
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('ui.echoareas.selected_color', {}, locale, ['common']) }}
+
+
-
- - -
{{ t('ui.echoareas.posting_name_policy_help', {}, locale, ['common']) }}
-
+
+ -
- - -
{{ t('ui.echoareas.art_format_hint_help', {}, locale, ['common']) }}
-
- -
+ +
+ + +
+
+

+ +

+
+
+
+
{{ t('ui.qwk.echoarea.subscriptions_help', {}, locale, ['common']) }}
+ +
+
+
+
+
+ +
+

+ +

+
+
+
{{ t('ui.qwk.echoarea.relay_help', {}, locale, ['common']) }}
+
+ + + + + + +
+
+ +
{{ t('ui.qwk.echoarea.relay_rules_help', {}, locale, ['common']) }}
+
+
+
+
+
+ +
+

+ +

+
+
+
+
{{ t('ui.qwk.echoarea.gates_help', {}, locale, ['common']) }}
+ +
+
+
+
+
+
+
+ {{ t('ui.qwk.echoarea.local_only_notice', {}, locale, ['common']) }} +
+
@@ -280,8 +370,24 @@
+ + {% endblock %} {% block scripts %} +
+ ${escapeHtml(uiT('ui.admin.networks.none', 'No networks found'))} +
${escapeHtml(user.username)} ${escapeHtml(user.real_name || '')} ${statusBadge}${adminBadge}${systemBadge}${adminBadge}${systemBadge}${bbsBadge} ${createdDate} ${lastLogin} ${lastReminded}