From a8aaeeb6ae55be5d76e15561b24a8dba9abe5e6b Mon Sep 17 00:00:00 2001 From: awehttam Date: Thu, 21 May 2026 23:28:24 -0700 Subject: [PATCH 01/19] Accept QWK REP uploads at FTP root for Synchronet QNET-FTP compatibility Synchronet's qnet-ftp.js STORs the reply packet in the current working directory without issuing a CWD first, so root-level .rep/.zip uploads were being rejected. FtpVirtualFilesystem::isQwkUploadFile() now also matches a bare *.rep/*.zip at path depth 1, and FtpServer routes those transfers through importUploadedRep instead of storeIncomingUpload. Co-Authored-By: Claude Sonnet 4.6 --- docs/UPGRADING_1.9.7.md | 9 +++++++++ src/Ftp/FtpServer.php | 5 ++++- src/Ftp/FtpVirtualFilesystem.php | 12 +++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/UPGRADING_1.9.7.md b/docs/UPGRADING_1.9.7.md index b97dac6e1..a1fafc9d5 100644 --- a/docs/UPGRADING_1.9.7.md +++ b/docs/UPGRADING_1.9.7.md @@ -74,6 +74,7 @@ In the web interface, chat rooms now render inline media automatically, inline c - [Echo Areas .NA File Import](#echo-areas-na-file-import) - [CheeseNet Network Added](#cheesenet-network-added) - [New Echo Areas Load More](#new-echo-areas-load-more) + - [QWK FTP Root Upload](#qwk-ftp-root-upload) - [Auto Feed](#auto-feed-1) - [Reply Threading](#reply-threading) - [Developer Tooling](#developer-tooling-1) @@ -728,6 +729,14 @@ The feature becomes available as soon as the updated files are deployed. --- +### QWK FTP Root Upload + +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. + +--- + ## Developer Tooling The root `CLAUDE.md` file previously contained all project guidance in a single document. It has been refactored so that sections relevant only to a specific subdirectory now live in a `CLAUDE.md` file within that directory (auto-loaded by Claude Code when working there). Subdirectory files were added for `scripts/`, `telnet/`, `ssh/`, `templates/`, and `public_html/webdoors/`. diff --git a/src/Ftp/FtpServer.php b/src/Ftp/FtpServer.php index 15c70a16f..ad8cc7066 100644 --- a/src/Ftp/FtpServer.php +++ b/src/Ftp/FtpServer.php @@ -735,7 +735,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..87f4dbf91 100644 --- a/src/Ftp/FtpVirtualFilesystem.php +++ b/src/Ftp/FtpVirtualFilesystem.php @@ -1262,12 +1262,18 @@ private function isQwkDownloadFile(array $user, string $path): bool 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; } /** From c5a2466196b0db2bc881b695b46bca2d59794373 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 22 May 2026 19:00:57 -0700 Subject: [PATCH 02/19] WIP: add QWK network exchange support --- config/i18n/de/common.php | 31 ++ config/i18n/de/errors.php | 3 + config/i18n/en/common.php | 31 ++ config/i18n/en/errors.php | 3 + config/i18n/es/common.php | 31 ++ config/i18n/es/errors.php | 3 + config/i18n/fr/common.php | 31 ++ config/i18n/fr/errors.php | 3 + config/i18n/it/common.php | 31 ++ config/i18n/it/errors.php | 3 + ...260523012838_qwk_mail_exchange_support.sql | 88 ++++ docs/API.md | 261 +++++++++- docs/CLI.md | 24 + docs/DATA_MODEL.md | 57 +- docs/QWK.md | 31 ++ routes/api-routes.php | 188 ++++++- scripts/qwk_poll.php | 112 ++++ src/MessageHandler.php | 159 +++++- src/Qwk/GateProcessor.php | 211 ++++++++ src/Qwk/QwkInbound.php | 111 ++++ src/Qwk/QwkMessage.php | 41 ++ src/Qwk/QwkOutbound.php | 88 ++++ src/Qwk/QwkPacketParser.php | 209 ++++++++ src/Qwk/QwkPoller.php | 86 +++ src/Qwk/QwkSubscriptionManager.php | 93 ++++ src/Qwk/QwkUplinkManager.php | 149 ++++++ src/Qwk/RepPacketBuilder.php | 68 +++ src/Qwk/Transport/FtpTransport.php | 83 +++ src/Qwk/Transport/TransportInterface.php | 10 + src/SysK.php | 75 +++ templates/echoareas.twig | 493 +++++++++++++++++- 31 files changed, 2789 insertions(+), 18 deletions(-) create mode 100644 database/migrations/v20260523012838_qwk_mail_exchange_support.sql create mode 100755 scripts/qwk_poll.php create mode 100644 src/Qwk/GateProcessor.php create mode 100644 src/Qwk/QwkInbound.php create mode 100644 src/Qwk/QwkMessage.php create mode 100644 src/Qwk/QwkOutbound.php create mode 100644 src/Qwk/QwkPacketParser.php create mode 100644 src/Qwk/QwkPoller.php create mode 100644 src/Qwk/QwkSubscriptionManager.php create mode 100644 src/Qwk/QwkUplinkManager.php create mode 100644 src/Qwk/RepPacketBuilder.php create mode 100644 src/Qwk/Transport/FtpTransport.php create mode 100644 src/Qwk/Transport/TransportInterface.php create mode 100644 src/SysK.php diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index a41ce3914..e414c19fe 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -4959,4 +4959,35 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.qwk.uplinks.manage' => 'QWK Uplinks', + 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', + 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', + 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK uplink saved', + 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', + 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + 'ui.qwk.echoarea.subscriptions_title' => 'QWK Subscriptions', + 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', + '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' => 'Uplink', + 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', + 'ui.qwk.echoarea.conference_number' => 'Conference #', + '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..566dcff8b 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -722,4 +722,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 uplink not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', ]; diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 913f1ba01..a12ed5337 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -4981,4 +4981,35 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.qwk.uplinks.manage' => 'QWK Uplinks', + 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', + 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', + 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK uplink saved', + 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', + 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + 'ui.qwk.echoarea.subscriptions_title' => 'QWK Subscriptions', + 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', + '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' => 'Uplink', + 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', + 'ui.qwk.echoarea.conference_number' => 'Conference #', + '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..05d4dc19e 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -722,4 +722,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 uplink not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', ]; diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index ebd0a95e1..d48bd4dd4 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -4947,4 +4947,35 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.qwk.uplinks.manage' => 'QWK Uplinks', + 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', + 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', + 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK uplink saved', + 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', + 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + 'ui.qwk.echoarea.subscriptions_title' => 'QWK Subscriptions', + 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', + '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' => 'Uplink', + 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', + 'ui.qwk.echoarea.conference_number' => 'Conference #', + '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..1c32a4bf5 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -720,4 +720,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 uplink not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', ]; diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index 822aa669a..f77b5333b 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -4886,4 +4886,35 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.qwk.uplinks.manage' => 'QWK Uplinks', + 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', + 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', + 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK uplink saved', + 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', + 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + 'ui.qwk.echoarea.subscriptions_title' => 'QWK Subscriptions', + 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', + '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' => 'Uplink', + 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', + 'ui.qwk.echoarea.conference_number' => 'Conference #', + '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..8eb0df268 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -679,6 +679,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 uplink not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', ]; diff --git a/config/i18n/it/common.php b/config/i18n/it/common.php index ba37f1a7f..dd2181aa3 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -4944,4 +4944,35 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.qwk.uplinks.manage' => 'QWK Uplinks', + 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', + 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', + 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + 'ui.qwk.uplinks.bbs_id' => 'Remote BBS ID', + 'ui.qwk.uplinks.remote_path' => 'Remote FTP Path', + 'ui.qwk.uplinks.poll_schedule' => 'Poll Schedule', + 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', + 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', + 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', + 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', + 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + 'ui.qwk.uplinks.last_polled' => 'Last polled', + 'ui.qwk.uplinks.poll_now' => 'Poll now', + 'ui.qwk.uplinks.saved' => 'QWK uplink saved', + 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', + 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', + 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + 'ui.qwk.echoarea.subscriptions_title' => 'QWK Subscriptions', + 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', + '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' => 'Uplink', + 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', + 'ui.qwk.echoarea.conference_number' => 'Conference #', + '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..50a69983f 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -720,4 +720,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 uplink not found', + 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', + 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', ]; 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..7ae423482 --- /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 the QWK tables before wiring all echomail references. +CREATE TABLE IF NOT EXISTS qwk_uplinks ( + 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, + uplink_id INTEGER NOT NULL REFERENCES qwk_uplinks(id) ON DELETE CASCADE, + conference_tag VARCHAR(50) NOT NULL, + conference_number INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT echo_area_qwk_subscriptions_area_uplink_key UNIQUE (echoarea_id, uplink_id), + CONSTRAINT echo_area_qwk_subscriptions_uplink_conf_key UNIQUE (uplink_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, + uplink_id INTEGER NOT NULL REFERENCES qwk_uplinks(id) ON DELETE CASCADE, + queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ NULL, + CONSTRAINT qwk_outbound_messages_unique UNIQUE (echomail_id, uplink_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_uplink_id INTEGER REFERENCES qwk_uplinks(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_uplink + ON echo_area_qwk_subscriptions (uplink_id); + +CREATE INDEX IF NOT EXISTS idx_qwk_outbound_pending + ON qwk_outbound_messages (uplink_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_uplink_id, qwk_conference_number, qwk_msg_number) + WHERE qwk_uplink_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/docs/API.md b/docs/API.md index ffb9b60a3..0676bc8a4 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` @@ -2064,6 +2066,75 @@ 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, local gate rules, all configured QWK uplinks, +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 | +| `gates` | array | Gate definitions involving this echo area | +| `uplinks` | array | All configured QWK uplinks | +| `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 +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 `{uplink_id, conference_tag, conference_number}` 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 +5950,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-uplinks`](#get-apiqwk-uplinks) | Yes | List configured QWK uplinks for the admin UI. | +| `GET` | [`/api/qwk-uplinks/{id}`](#get-apiqwk-uplinksid) | Yes | Load one QWK uplink including its decrypted password for editing. | +| `POST` | [`/api/qwk-uplinks`](#post-apiqwk-uplinks) | Yes | Create a QWK uplink configuration. | +| `PUT` | [`/api/qwk-uplinks/{id}`](#put-apiqwk-uplinksid) | Yes | Update a QWK uplink configuration. | +| `DELETE` | [`/api/qwk-uplinks/{id}`](#delete-apiqwk-uplinksid) | Yes | Delete a QWK uplink configuration. | +| `POST` | [`/api/qwk-uplinks/{id}/poll`](#post-apiqwk-uplinksidpoll) | Yes | Poll one QWK uplink immediately. | #### `POST /api/qwk/upload` @@ -6081,6 +6158,186 @@ Search results --- +#### `GET /api/qwk-uplinks` + +**Requires authentication** + +Admin-only endpoint that lists all configured QWK uplinks for the management +UI. Passwords are not returned by this list endpoint. + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `uplinks` | array | QWK uplink records with status metadata | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | + +--- + +#### `GET /api/qwk-uplinks/{id}` + +**Requires authentication** + +Admin-only endpoint that returns one QWK uplink for editing, including the +decrypted password in `password_plain`. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK uplink ID | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `uplink` | object | Full QWK uplink record | +| `uplink.password_plain` | string | Decrypted password for edit-form reuse | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 404 | QWK uplink not found | + +--- + +#### `POST /api/qwk-uplinks` + +**Requires authentication** + +Admin-only endpoint that creates a new QWK uplink definition. + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Friendly uplink 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 uplink ID | +| `message_code` | string | Localization key for UI success messaging | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 403 | Admin privileges required | +| 400 | Invalid uplink payload or save failure | + +--- + +#### `PUT /api/qwk-uplinks/{id}` + +**Requires authentication** + +Admin-only endpoint that updates an existing QWK uplink. If `password` is sent +blank, the previous password is retained. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK uplink ID | + +**Request Body** _(JSON)_ + +Same shape as `POST /api/qwk-uplinks`. + +**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 uplink payload or save failure | + +--- + +#### `DELETE /api/qwk-uplinks/{id}` + +**Requires authentication** + +Admin-only endpoint that deletes a configured QWK uplink. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK uplink 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 uplink not found | + +--- + +#### `POST /api/qwk-uplinks/{id}/poll` + +**Requires authentication** + +Admin-only endpoint that immediately polls one QWK uplink, imports any inbound +packet, builds an outbound `.REP` if needed, and attempts upload. + +**Path Parameters** + +| Name | Type | Description | +|------|------|-------------| +| `id` | integer | QWK uplink 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 | diff --git a/docs/CLI.md b/docs/CLI.md index 1b7efe891..7ef064ad9 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,29 @@ Options: - `--verbose` — Show detailed output - `--dry-run` — Check queue without attempting delivery +## QWK Mail Exchange Poll + +Polls configured QWK uplinks, 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. + +```bash +# Poll all enabled QWK uplinks +php scripts/qwk_poll.php --all + +# Poll one configured uplink 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 uplink +- `--quiet` — Print only success/failure status +- `--help` — Show usage +- `--log-level=LVL`, `--log-file=FILE`, `--no-console` — Accepted for scheduler compatibility + ## 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..fc8ba4e3b 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_uplink_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. @@ -125,6 +127,58 @@ 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. +### `qwk_uplinks` + +Admin-configured remote QWK systems that this BBS can poll like a client. + +| Column | Notes | +|--------|-------| +| `id` | Primary key | +| `name` | Friendly admin label | +| `bbs_id` | Remote QWK packet ID (up to 8 characters) | +| `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 uplink 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 uplink. + +| Column | Notes | +|--------|-------| +| `echoarea_id` | FK → `echoareas.id` | +| `uplink_id` | FK → `qwk_uplinks.id` | +| `conference_tag` | Remote or admin label for the conference | +| `conference_number` | Remote QWK conference number used in packets | + +These rows drive both directions: inbound `.QWK` import routing and outbound +`.REP` queue generation. + +### `qwk_outbound_messages` + +Queue table for local echomail messages that still need to be exported to one +or more QWK uplinks. + +| Column | Notes | +|--------|-------| +| `echomail_id` | FK → `echomail.id` | +| `uplink_id` | FK → `qwk_uplinks.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 used by the QWK exchange feature. + +| 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 +216,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_uplinks` / `echo_area_qwk_subscriptions` / `qwk_outbound_messages` / `echo_area_gates` | QWK network exchange configuration, 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/QWK.md b/docs/QWK.md index a507f08a6..739f59a50 100644 --- a/docs/QWK.md +++ b/docs/QWK.md @@ -10,6 +10,7 @@ FidoNet metadata. ## Table of Contents - [How It Works](#how-it-works) +- [QWK Network Exchange](#qwk-network-exchange) - [Packet Formats](#packet-formats) - [QWK](#qwk) - [QWKE](#qwke) @@ -55,6 +56,36 @@ your replies back to the correct echo areas. --- +## QWK Network Exchange + +BinktermPHP can also act as a QWK client for another BBS. In this mode the +local system polls a remote QWK uplink, 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. + +This is configured from the admin web interface: + +1. Open **Admin → Echo Areas**. +2. Use **QWK Uplinks** to define the remote BBS ID, FTP host, credentials, and + remote path. +3. Edit a local echo area and add one or more **QWK Subscriptions** mapping the + local area to remote conference numbers. +4. Optionally add **Gates** to mirror imported or local traffic into other + local areas. + +The transport/poll cycle is driven by `php scripts/qwk_poll.php --all` or by +polling a single uplink ID. + +Important behavior: + +- Inbound deduplication uses `(qwk_uplink_id, qwk_conference_number, + qwk_msg_number)`. +- Outbound replies preserve QWK reply threading when the parent message came + from the same uplink and conference. +- Gated local copies use `source_msgid` to prevent loops and duplicate mirrors. + +--- + ## Packet Formats ### QWK diff --git a/routes/api-routes.php b/routes/api-routes.php index ced34c7de..a99e3895f 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 { @@ -2553,6 +2555,190 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array ]); }); + SimpleRouter::get('/qwk-uplinks', 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\QwkUplinkManager(); + echo json_encode(['uplinks' => $manager->getAll()]); + }); + + SimpleRouter::get('/qwk-uplinks/{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\QwkUplinkManager(); + $uplink = $manager->getById((int)$id, true); + if (!$uplink) { + http_response_code(404); + apiError('errors.qwk.uplink_not_found', apiLocalizedText('errors.qwk.uplink_not_found', 'QWK uplink not found', $user)); + } + + echo json_encode(['uplink' => $uplink]); + })->where(['id' => '[0-9]+']); + + SimpleRouter::post('/qwk-uplinks', 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\QwkUplinkManager(); + $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 uplink 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-uplinks/{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\QwkUplinkManager(); + $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 uplink 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-uplinks/{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\QwkUplinkManager(); + if (!$manager->delete((int)$id)) { + http_response_code(404); + apiError('errors.qwk.uplink_not_found', apiLocalizedText('errors.qwk.uplink_not_found', 'QWK uplink not found', $user)); + } + echo json_encode(['success' => true, 'message_code' => 'ui.qwk.uplinks.deleted']); + })->where(['id' => '[0-9]+']); + + SimpleRouter::post('/qwk-uplinks/{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'); + $result = (new \BinktermPHP\Qwk\QwkPoller())->pollUplink((int)$id); + if (empty($result['success'])) { + http_response_code(400); + apiError('errors.qwk.poll_failed', apiLocalizedText('errors.qwk.poll_failed', 'Failed to poll QWK uplink', $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\Qwk\GateProcessor($db); + $uplinkManager = new \BinktermPHP\Qwk\QwkUplinkManager($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), + 'uplinks' => $uplinkManager->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'] : []; + + try { + $db->beginTransaction(); + (new \BinktermPHP\Qwk\QwkSubscriptionManager($db))->replaceAreaSubscriptions($echoareaId, $subscriptions); + (new \BinktermPHP\Qwk\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(); diff --git a/scripts/qwk_poll.php b/scripts/qwk_poll.php new file mode 100755 index 000000000..d4913e8b4 --- /dev/null +++ b/scripts/qwk_poll.php @@ -0,0 +1,112 @@ +#!/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]; +} + +[$args, $positional] = qwkPollParseArgs($argv); +if (isset($args['help'])) { + qwkPollShowUsage(); + exit(0); +} + +$quiet = isset($args['quiet']); +$poller = new \BinktermPHP\Qwk\QwkPoller(); + +try { + if (isset($args['all'])) { + $results = $poller->pollAllEnabled(); + 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 (array_key_exists('uploaded', $result)) { + echo ' Uploaded REP: ' . (!empty($result['uploaded']) ? 'yes' : 'no') . "\n"; + } + if (!empty($result['error'])) { + echo ' Error: ' . $result['error'] . "\n"; + } + } + + $allOk = array_reduce($results, static function (bool $carry, array $result): bool { + return $carry && !empty($result['success']); + }, true); + exit($allOk ? 0 : 1); + } + + if ($positional === []) { + qwkPollShowUsage(); + exit(1); + } + + $uplinkId = (int)$positional[0]; + $result = $poller->pollUplink($uplinkId); + 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 (array_key_exists('uploaded', $result)) { + echo 'Uploaded REP: ' . (!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 ($quiet) { + echo "FAIL\n"; + } else { + echo 'Error: ' . $e->getMessage() . "\n"; + } + exit(1); +} diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 9a48a8d7c..f53c0ed4d 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -1893,12 +1893,122 @@ 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 and can fan out to the area's + * FTN uplink, QWK subscriptions, and gates. + * + * @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_uplink_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_uplink_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_uplink_id', !empty($data['qwk_uplink_id']) ? (int)$data['qwk_uplink_id'] : null, !empty($data['qwk_uplink_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); + $this->finalizeApprovedEchomailDelivery( + $messageId, + (string)$echoarea['tag'], + $domain, + isset($data['exclude_qwk_uplink_id']) ? (int)$data['exclude_qwk_uplink_id'] : null, + !array_key_exists('apply_gates', $data) || !empty($data['apply_gates']) + ); + + 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 +2059,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 +2778,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. * @@ -3259,6 +3376,44 @@ private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) } } + private function finalizeApprovedEchomailDelivery(int $messageId, string $echoareaTag, ?string $domain, ?int $excludeQwkUplinkId = null, bool $applyGates = true): void + { + $this->spoolOutboundEchomail($messageId, $echoareaTag, (string)$domain); + $this->queueQwkOutboundEchomail($messageId, $excludeQwkUplinkId); + if ($applyGates) { + (new \BinktermPHP\Qwk\GateProcessor($this->db, $this))->processMessageById($messageId); + } + } + + private function queueQwkOutboundEchomail(int $messageId, ?int $excludeQwkUplinkId = null): void + { + $stmt = $this->db->prepare("SELECT echoarea_id FROM echomail WHERE id = ?"); + $stmt->execute([$messageId]); + $echoareaId = $stmt->fetchColumn(); + if (!$echoareaId) { + 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, uplink_id, queued_at) + VALUES (?, ?, NOW()) + ON CONFLICT (echomail_id, uplink_id) DO NOTHING + "); + + foreach ($subscriptions as $subscription) { + $uplinkId = (int)$subscription['uplink_id']; + if ($excludeQwkUplinkId !== null && $uplinkId === $excludeQwkUplinkId) { + continue; + } + $insert->execute([$messageId, $uplinkId]); + } + } + /** 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/Qwk/GateProcessor.php b/src/Qwk/GateProcessor.php new file mode 100644 index 000000000..bcf9a8a57 --- /dev/null +++ b/src/Qwk/GateProcessor.php @@ -0,0 +1,211 @@ +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 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'); + } + + $sourceId = $echoareaId < $targetId ? $echoareaId : $targetId; + $normalizedTargetId = $echoareaId < $targetId ? $targetId : $echoareaId; + $insertStmt->execute([$sourceId, $normalizedTargetId, $bidirectional ? 'true' : '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 + */ + 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)); + } + + /** + * @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; + } + + 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/Qwk/QwkInbound.php b/src/Qwk/QwkInbound.php new file mode 100644 index 000000000..f729cca61 --- /dev/null +++ b/src/Qwk/QwkInbound.php @@ -0,0 +1,111 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->parser = $parser ?? new QwkPacketParser(); + $this->subscriptions = $subscriptions ?? new QwkSubscriptionManager($this->db); + $this->messageHandler = $messageHandler ?? new MessageHandler(); + } + + /** + * @return array{imported:int,skipped:int} + */ + public function importPacket(int $uplinkId, string $zipPath): array + { + $parsed = $this->parser->parsePacket($zipPath); + $imported = 0; + $skipped = 0; + + foreach ($parsed['messages'] as $message) { + if ($message->conferenceNumber <= 0) { + $skipped++; + continue; + } + + $subscription = $this->subscriptions->getSubscriptionForConference($uplinkId, $message->conferenceNumber); + if ($subscription === null) { + $skipped++; + continue; + } + + if ($this->messageExists($uplinkId, $message->conferenceNumber, $message->messageNumber)) { + $skipped++; + continue; + } + + $replyToId = $this->findReplyToId($uplinkId, $message->conferenceNumber, $message->replyToNumber); + $sourceMsgId = $message->sourceMsgId ?: sprintf('qwk:%d:%d:%d', $uplinkId, $message->conferenceNumber, $message->messageNumber); + + $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' => null, + 'reply_to_id' => $replyToId, + 'source_msgid' => $sourceMsgId, + 'qwk_uplink_id' => $uplinkId, + 'qwk_conference_number' => $message->conferenceNumber, + 'qwk_msg_number' => $message->messageNumber, + 'exclude_qwk_uplink_id' => $uplinkId, + 'apply_gates' => true, + ]); + + if ($newId > 0) { + $imported++; + } else { + $skipped++; + } + } + + return ['imported' => $imported, 'skipped' => $skipped]; + } + + private function messageExists(int $uplinkId, int $conferenceNumber, int $messageNumber): bool + { + $stmt = $this->db->prepare(" + SELECT 1 + FROM echomail + WHERE qwk_uplink_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? + LIMIT 1 + "); + $stmt->execute([$uplinkId, $conferenceNumber, $messageNumber]); + return (bool)$stmt->fetchColumn(); + } + + private function findReplyToId(int $uplinkId, int $conferenceNumber, int $messageNumber): ?int + { + if ($messageNumber <= 0) { + return null; + } + + $stmt = $this->db->prepare(" + SELECT id + FROM echomail + WHERE qwk_uplink_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? + LIMIT 1 + "); + $stmt->execute([$uplinkId, $conferenceNumber, $messageNumber]); + $id = $stmt->fetchColumn(); + return $id ? (int)$id : null; + } +} diff --git a/src/Qwk/QwkMessage.php b/src/Qwk/QwkMessage.php new file mode 100644 index 000000000..86fc8db96 --- /dev/null +++ b/src/Qwk/QwkMessage.php @@ -0,0 +1,41 @@ +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..5746f4501 --- /dev/null +++ b/src/Qwk/QwkOutbound.php @@ -0,0 +1,88 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->builder = $builder ?? new RepPacketBuilder(); + } + + public function buildPendingRepPacket(array $uplink): ?string + { + $rows = $this->getPendingMessages((int)$uplink['id']); + if ($rows === []) { + return null; + } + + $messages = []; + foreach ($rows as $row) { + $replyToNum = 0; + if (!empty($row['reply_qwk_uplink_id']) + && (int)$row['reply_qwk_uplink_id'] === (int)$uplink['id'] + && (int)$row['reply_qwk_conference_number'] === (int)$row['conference_number'] + ) { + $replyToNum = (int)$row['reply_qwk_msg_number']; + } + + $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, + ]; + } + + return $this->builder->build((string)$uplink['bbs_id'], $messages); + } + + public function markUploaded(int $uplinkId): void + { + $stmt = $this->db->prepare(" + UPDATE qwk_outbound_messages + SET sent_at = NOW() + WHERE uplink_id = ? AND sent_at IS NULL + "); + $stmt->execute([$uplinkId]); + } + + /** + * @return array> + */ + private function getPendingMessages(int $uplinkId): array + { + $stmt = $this->db->prepare(" + SELECT qom.id AS queue_id, + em.id AS echomail_id, + em.to_name, + em.from_name, + em.subject, + em.message_text, + s.conference_number, + parent.qwk_uplink_id AS reply_qwk_uplink_id, + parent.qwk_conference_number AS reply_qwk_conference_number, + parent.qwk_msg_number AS reply_qwk_msg_number + 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.uplink_id = qom.uplink_id + LEFT JOIN echomail parent ON parent.id = em.reply_to_id + WHERE qom.uplink_id = ? + AND qom.sent_at IS NULL + ORDER BY qom.id ASC + "); + $stmt->execute([$uplinkId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } +} diff --git a/src/Qwk/QwkPacketParser.php b/src/Qwk/QwkPacketParser.php new file mode 100644 index 000000000..1eada6d44 --- /dev/null +++ b/src/Qwk/QwkPacketParser.php @@ -0,0 +1,209 @@ +,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'); + } + + 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; + } + + return [ + 'bbs_name' => trim((string)($lines[0] ?? '')), + 'bbs_id' => strtoupper(trim((string)explode(',', (string)($lines[4] ?? '0,'))[1] ?? '')), + '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'); + } + + $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; + } + + 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); + + $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]; + } + + /** + * @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..0356ccaec --- /dev/null +++ b/src/Qwk/QwkPoller.php @@ -0,0 +1,86 @@ +uplinks = $uplinks ?? new QwkUplinkManager(); + $this->inbound = $inbound ?? new QwkInbound(); + $this->outbound = $outbound ?? new QwkOutbound(); + $this->transport = $transport ?? new FtpTransport($this->uplinks); + } + + /** + * @return array + */ + public function pollUplink(int $uplinkId): array + { + $uplink = $this->uplinks->getById($uplinkId, true); + if ($uplink === null) { + throw new \InvalidArgumentException('QWK uplink not found'); + } + + $downloadedPath = null; + $repPath = null; + try { + $stats = ['imported' => 0, 'skipped' => 0, 'uploaded' => false]; + + $downloadedPath = $this->transport->downloadPacket($uplink); + if ($downloadedPath !== null) { + $importStats = $this->inbound->importPacket($uplinkId, $downloadedPath); + $stats['imported'] = $importStats['imported']; + $stats['skipped'] = $importStats['skipped']; + } + + $repPath = $this->outbound->buildPendingRepPacket($uplink); + if ($repPath !== null) { + $stats['uploaded'] = $this->transport->uploadPacket($uplink, $repPath); + if ($stats['uploaded']) { + $this->outbound->markUploaded($uplinkId); + } + } + + $this->uplinks->markPollResult($uplinkId, null); + return array_merge(['success' => true], $stats); + } catch (\Throwable $e) { + $this->uplinks->markPollResult($uplinkId, $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->uplinks->getAll() as $uplink) { + if (empty($uplink['enabled'])) { + continue; + } + $results[(string)$uplink['name']] = $this->pollUplink((int)$uplink['id']); + } + return $results; + } +} diff --git a/src/Qwk/QwkSubscriptionManager.php b/src/Qwk/QwkSubscriptionManager.php new file mode 100644 index 000000000..98f5d40d0 --- /dev/null +++ b/src/Qwk/QwkSubscriptionManager.php @@ -0,0 +1,93 @@ +db = $db ?? Database::getInstance()->getPdo(); + } + + /** + * @return array> + */ + public function getSubscriptionsForArea(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT s.id, s.echoarea_id, s.uplink_id, s.conference_tag, s.conference_number, + u.name AS uplink_name, u.bbs_id AS uplink_bbs_id, u.enabled AS uplink_enabled + FROM echo_area_qwk_subscriptions s + JOIN qwk_uplinks u ON u.id = s.uplink_id + WHERE s.echoarea_id = ? + ORDER BY LOWER(u.name), s.conference_number + "); + $stmt->execute([$echoareaId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + /** + * @return array> + */ + public function getSubscriptionsForUplink(int $uplinkId): 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.uplink_id = ? + ORDER BY s.conference_number, e.id + "); + $stmt->execute([$uplinkId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + public function getSubscriptionForConference(int $uplinkId, 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.uplink_id = ? AND s.conference_number = ? + LIMIT 1 + "); + $stmt->execute([$uplinkId, $conferenceNumber]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + /** + * @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, uplink_id, conference_tag, conference_number, created_at) + VALUES (?, ?, ?, ?, NOW()) + "); + + foreach ($subscriptions as $subscription) { + $uplinkId = (int)($subscription['uplink_id'] ?? 0); + $conferenceNumber = (int)($subscription['conference_number'] ?? 0); + $conferenceTag = trim((string)($subscription['conference_tag'] ?? '')); + if ($uplinkId <= 0 || $conferenceNumber < 0 || $conferenceTag === '') { + throw new \InvalidArgumentException('Invalid QWK subscription payload'); + } + + $insertStmt->execute([$echoareaId, $uplinkId, $conferenceTag, $conferenceNumber]); + } + } +} diff --git a/src/Qwk/QwkUplinkManager.php b/src/Qwk/QwkUplinkManager.php new file mode 100644 index 000000000..b4c718950 --- /dev/null +++ b/src/Qwk/QwkUplinkManager.php @@ -0,0 +1,149 @@ +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, + poll_schedule, enabled, last_polled_at, last_error, created_at, updated_at + FROM qwk_uplinks + ORDER BY LOWER(name), id + "); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($rows as &$row) { + if ($includeSecrets) { + $row['password_plain'] = $this->decryptPassword((string)($row['password'] ?? '')); + } else { + unset($row['password']); + } + } + unset($row); + return $rows; + } + + 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, + poll_schedule, enabled, last_polled_at, last_error, created_at, updated_at + FROM qwk_uplinks + WHERE id = ? + "); + $stmt->execute([$id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + + if ($includeSecret) { + $row['password_plain'] = $this->decryptPassword((string)($row['password'] ?? '')); + } else { + unset($row['password']); + } + + return $row; + } + + /** + * @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'] ?? '/')); + $schedule = trim((string)($data['poll_schedule'] ?? '')); + $enabled = !empty($data['enabled']); + + if ($name === '' || $bbsId === '' || $host === '' || $username === '') { + throw new \InvalidArgumentException('Missing required uplink fields'); + } + + if ($port < 1 || $port > 65535) { + throw new \InvalidArgumentException('Invalid port'); + } + + if ($id === null && $password === '') { + throw new \InvalidArgumentException('Password is required for new QWK uplinks'); + } + + if ($id !== null && $password === '') { + $existing = $this->getById($id, true); + if (!$existing) { + throw new \InvalidArgumentException('QWK uplink not found'); + } + $password = (string)($existing['password_plain'] ?? ''); + } + + $encryptedPassword = SysK::encrypt($password); + + if ($id === null) { + $stmt = $this->db->prepare(" + INSERT INTO qwk_uplinks + (name, bbs_id, host, port, username, password, ftp_remote_path, poll_schedule, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + RETURNING id + "); + $stmt->execute([$name, $bbsId, $host, $port, $username, $encryptedPassword, $path, $schedule !== '' ? $schedule : null, $enabled ? 'true' : 'false']); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? (int)$row['id'] : 0; + } + + $stmt = $this->db->prepare(" + UPDATE qwk_uplinks + SET name = ?, bbs_id = ?, host = ?, port = ?, username = ?, password = ?, + ftp_remote_path = ?, poll_schedule = ?, enabled = ?, updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([$name, $bbsId, $host, $port, $username, $encryptedPassword, $path, $schedule !== '' ? $schedule : null, $enabled ? 'true' : 'false', $id]); + if ($stmt->rowCount() === 0) { + throw new \InvalidArgumentException('QWK uplink not found'); + } + return $id; + } + + public function delete(int $id): bool + { + $stmt = $this->db->prepare("DELETE FROM qwk_uplinks WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; + } + + public function markPollResult(int $id, ?string $error = null): void + { + $stmt = $this->db->prepare(" + UPDATE qwk_uplinks + SET last_polled_at = NOW(), + last_error = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([$error, $id]); + } + + public function decryptPassword(string $encrypted): string + { + return SysK::decrypt($encrypted); + } +} diff --git a/src/Qwk/RepPacketBuilder.php b/src/Qwk/RepPacketBuilder.php new file mode 100644 index 000000000..5bcc3156a --- /dev/null +++ b/src/Qwk/RepPacketBuilder.php @@ -0,0 +1,68 @@ +> $messages + */ + public function build(string $bbsId, array $messages): string + { + $msgData = str_pad('Produced by BinktermPHP v' . Version::getVersion(), self::BLOCK_SIZE, "\x00"); + $logical = 1; + foreach ($messages as $message) { + $msgData .= $this->encodeMessage($message, $logical); + $logical++; + } + + $zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . strtoupper($bbsId) . '_' . 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(strtoupper($bbsId) . '.MSG', $msgData); + $zip->close(); + + return $zipPath; + } + + /** + * @param array $message + */ + private function encodeMessage(array $message, int $logicalNumber): string + { + $body = str_replace(["\r\n", "\r", "\n"], self::LINE_TERM, rtrim((string)$message['body'])) . self::LINE_TERM; + $cp437 = @iconv('UTF-8', 'CP437//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')); + + $header = '+'; + $header .= str_pad((string)$logicalNumber, 7, ' ', STR_PAD_LEFT); + $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('', 12, "\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); + $confNumber = (int)$message['conference_number']; + $header .= chr($confNumber & 0xFF); + $header .= chr(($confNumber >> 8) & 0xFF); + $header .= "\x00\x00\x00"; + + $paddedBody = str_pad($body, $bodyBlockCount * self::BLOCK_SIZE, "\x00"); + return $header . $paddedBody; + } +} diff --git a/src/Qwk/Transport/FtpTransport.php b/src/Qwk/Transport/FtpTransport.php new file mode 100644 index 000000000..dd38cd708 --- /dev/null +++ b/src/Qwk/Transport/FtpTransport.php @@ -0,0 +1,83 @@ +uplinkManager = $uplinkManager ?? new QwkUplinkManager(); + } + + public function downloadPacket(array $uplink): ?string + { + $conn = $this->connect($uplink); + try { + $remotePath = $this->buildRemotePath($uplink, strtoupper((string)$uplink['bbs_id']) . '.QWK'); + $tmpPath = tempnam(sys_get_temp_dir(), 'qwkdl_'); + if ($tmpPath === false) { + throw new \RuntimeException('Failed to allocate temporary download path'); + } + + if (!@ftp_get($conn, $tmpPath, $remotePath, FTP_BINARY)) { + @unlink($tmpPath); + return null; + } + + return $tmpPath; + } finally { + @ftp_close($conn); + } + } + + public function uploadPacket(array $uplink, string $localPacketPath): bool + { + $conn = $this->connect($uplink); + try { + $remotePath = $this->buildRemotePath($uplink, strtoupper((string)$uplink['bbs_id']) . '.REP'); + return (bool)@ftp_put($conn, $remotePath, $localPacketPath, FTP_BINARY); + } finally { + @ftp_close($conn); + } + } + + /** + * @return resource + */ + private function connect(array $uplink) + { + if (!function_exists('ftp_connect')) { + throw new \RuntimeException('PHP FTP extension is not available'); + } + + $host = (string)$uplink['host']; + $port = (int)($uplink['port'] ?? 21); + $conn = @ftp_connect($host, $port, 20); + if ($conn === false) { + throw new \RuntimeException('Failed to connect to FTP host'); + } + + $password = $this->uplinkManager->decryptPassword((string)($uplink['password'] ?? '')); + if (!@ftp_login($conn, (string)$uplink['username'], $password)) { + @ftp_close($conn); + throw new \RuntimeException('FTP login failed'); + } + + @ftp_pasv($conn, true); + return $conn; + } + + private function buildRemotePath(array $uplink, string $filename): string + { + $base = trim((string)($uplink['ftp_remote_path'] ?? '/')); + if ($base === '' || $base === '.') { + return $filename; + } + + return rtrim(str_replace('\\', '/', $base), '/') . '/' . $filename; + } +} diff --git a/src/Qwk/Transport/TransportInterface.php b/src/Qwk/Transport/TransportInterface.php new file mode 100644 index 000000000..82ee31a50 --- /dev/null +++ b/src/Qwk/Transport/TransportInterface.php @@ -0,0 +1,10 @@ + {{ t('ui.common.import', {}, locale, ['common']) }} + + +
+ + +
+
+
+ +
{{ t('ui.qwk.echoarea.gates_help', {}, locale, ['common']) }}
+
+ +
+
+
@@ -293,6 +325,85 @@ + + {% endblock %} {% block scripts %} @@ -352,6 +463,9 @@ let pendingEchoareaTag = null; let pendingEchoareaDomain = null; let pendingEchoareaOpenHandled = false; let currentEchoareaInfo = null; +let currentQwkUplinkId = null; +let qwkUplinkCache = []; +let availableGateAreas = []; const configuredNetworkDomains = {{ networks|map(n => n.domain)|json_encode|raw }}; function apiError(payload, fallback) { @@ -693,8 +807,17 @@ function showAddEchoareaModal() { $('#echoareaLovlyNetSyncButton').addClass('d-none').prop('disabled', false).html( `${uiT('ui.echoareas.lovlynet_sync_button', 'Sync')}` ); + availableGateAreas = allEchoareas.map(area => ({ + id: area.id, + tag: area.tag, + domain: area.domain || '' + })); + renderQwkSubscriptions([]); + renderGateRows([]); updateColorSelection('#28a745'); - $('#echoareaModal').modal('show'); + ensureQwkUplinksLoaded().always(function() { + $('#echoareaModal').modal('show'); + }); } function ensureEchoareaDomainOption(domain) { @@ -738,7 +861,9 @@ function editEchoarea(id) { $('#echoareaAllowMedia').val(av === null || av === undefined ? 'inherit' : (av ? 'allow' : 'deny')); toggleLovlyNetSyncButton(area); updateColorSelection(area.color || '#28a745'); - $('#echoareaModal').modal('show'); + loadEchoareaQwkConfig(id).always(function() { + $('#echoareaModal').modal('show'); + }); }) .fail(function() { showError(uiT('ui.echoareas.load_details_failed', 'Failed to load echo area details')); @@ -833,16 +958,24 @@ function saveEchoarea() { contentType: 'application/json', data: JSON.stringify(formData), success: function(response) { - $('#echoareaModal').modal('hide'); - const fallbackMessage = currentEchoareaId - ? uiT('ui.echoareas.updated_success', 'Echo area updated successfully') - : uiT('ui.echoareas.created_success', 'Echo area created successfully'); - const successMessage = window.getApiMessage - ? window.getApiMessage(response, fallbackMessage) - : fallbackMessage; - showSuccess(successMessage); - loadEchoareas(); - loadStats(); + const savedAreaId = currentEchoareaId || parseInt(response.id || 0, 10); + saveEchoareaQwkConfig(savedAreaId) + .done(function() { + $('#echoareaModal').modal('hide'); + const fallbackMessage = currentEchoareaId + ? uiT('ui.echoareas.updated_success', 'Echo area updated successfully') + : uiT('ui.echoareas.created_success', 'Echo area created successfully'); + const successMessage = window.getApiMessage + ? window.getApiMessage(response, fallbackMessage) + : fallbackMessage; + showSuccess(successMessage); + loadEchoareas(); + loadStats(); + }) + .fail(function(xhr) { + const error = apiError(xhr.responseJSON, uiT('errors.qwk.save_failed', 'Failed to save QWK configuration')); + $('#echoareaModalError').text(error).removeClass('d-none'); + }); }, error: function(xhr) { const error = apiError( @@ -920,6 +1053,342 @@ function updateColorSelection(color) { $(`.color-swatch[data-color="${color}"]`).addClass('selected'); } +function ensureQwkUplinksLoaded() { + if (qwkUplinkCache.length > 0) { + return $.Deferred().resolve(qwkUplinkCache).promise(); + } + + return $.get('/api/qwk-uplinks') + .done(function(data) { + qwkUplinkCache = Array.isArray(data.uplinks) ? data.uplinks : []; + }); +} + +function loadEchoareaQwkConfig(id) { + return ensureQwkUplinksLoaded().then(function() { + return $.get(`/api/echoareas/${id}/qwk-config`); + }).done(function(data) { + availableGateAreas = Array.isArray(data.available_areas) ? data.available_areas : []; + qwkUplinkCache = Array.isArray(data.uplinks) ? data.uplinks : qwkUplinkCache; + renderQwkSubscriptions(Array.isArray(data.subscriptions) ? data.subscriptions : []); + renderGateRows(Array.isArray(data.gates) ? data.gates : []); + }) + .fail(function() { + renderQwkSubscriptions([]); + renderGateRows([]); + }); +} + +function renderQwkSubscriptions(subscriptions) { + const container = $('#qwkSubscriptionsContainer'); + if (!subscriptions.length) { + container.html(`
${uiT('ui.qwk.echoarea.none_configured', 'None configured')}
`); + return; + } + + let html = ''; + subscriptions.forEach(function(subscription) { + html += qwkSubscriptionRowHtml(subscription); + }); + container.html(html); +} + +function addQwkSubscriptionRow(subscription = {}) { + const container = $('#qwkSubscriptionsContainer'); + if (container.find('.qwk-subscription-row').length === 0) { + container.empty(); + } + container.append(qwkSubscriptionRowHtml(subscription)); +} + +function qwkSubscriptionRowHtml(subscription) { + const uplinkOptions = qwkUplinkCache.map(function(uplink) { + const selected = String(subscription.uplink_id || '') === String(uplink.id) ? 'selected' : ''; + return ``; + }).join(''); + + return ` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ `; +} + +function renderGateRows(gates) { + const container = $('#qwkGatesContainer'); + if (!gates.length) { + container.html(`
${uiT('ui.qwk.echoarea.none_configured', 'None configured')}
`); + return; + } + + let html = ''; + gates.forEach(function(gate) { + const targetId = String(gate.source_area_id) === String(currentEchoareaId) ? gate.target_area_id : gate.source_area_id; + html += gateRowHtml({ target_area_id: targetId, bidirectional: gate.bidirectional }); + }); + container.html(html); +} + +function addGateRow(gate = {}) { + const container = $('#qwkGatesContainer'); + if (container.find('.qwk-gate-row').length === 0) { + container.empty(); + } + container.append(gateRowHtml(gate)); +} + +function gateRowHtml(gate) { + const options = availableGateAreas.map(function(area) { + const label = area.domain ? `${area.tag}@${area.domain}` : area.tag; + const selected = String(gate.target_area_id || '') === String(area.id) ? 'selected' : ''; + return ``; + }).join(''); + + return ` +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ `; +} + +function ensureQwkEmptyState() { + if ($('#qwkSubscriptionsContainer').find('.qwk-subscription-row').length === 0) { + renderQwkSubscriptions([]); + } + if ($('#qwkGatesContainer').find('.qwk-gate-row').length === 0) { + renderGateRows([]); + } +} + +function collectEchoareaQwkConfig() { + const subscriptions = []; + $('#qwkSubscriptionsContainer .qwk-subscription-row').each(function() { + const uplinkId = parseInt($(this).find('.qwk-uplink-id').val(), 10); + const tag = String($(this).find('.qwk-conference-tag').val() || '').trim(); + const number = parseInt($(this).find('.qwk-conference-number').val(), 10); + if (uplinkId && tag !== '' && !Number.isNaN(number)) { + subscriptions.push({ + uplink_id: uplinkId, + conference_tag: tag, + conference_number: number + }); + } + }); + + const gates = []; + $('#qwkGatesContainer .qwk-gate-row').each(function() { + const targetId = parseInt($(this).find('.qwk-gate-target').val(), 10); + if (targetId) { + gates.push({ + target_area_id: targetId, + bidirectional: $(this).find('.qwk-gate-bidirectional').is(':checked') + }); + } + }); + + return { subscriptions, gates }; +} + +function saveEchoareaQwkConfig(echoareaId) { + if (!echoareaId) { + return $.Deferred().resolve().promise(); + } + + return $.ajax({ + url: `/api/echoareas/${echoareaId}/qwk-config`, + method: 'PUT', + contentType: 'application/json', + data: JSON.stringify(collectEchoareaQwkConfig()) + }); +} + +function showQwkUplinksModal() { + resetQwkUplinkForm(); + $('#qwkUplinkAlert').addClass('d-none').text(''); + $('#qwkUplinksModal').modal('show'); + loadQwkUplinkList(); +} + +function resetQwkUplinkForm() { + currentQwkUplinkId = null; + $('#qwkUplinkForm')[0].reset(); + $('#qwkUplinkFormTitle').text(uiT('ui.qwk.uplinks.add_title', 'Add QWK Uplink')); + $('#qwkUplinkPort').val(21); + $('#qwkUplinkPath').val('/'); + $('#qwkUplinkEnabled').prop('checked', true); +} + +function loadQwkUplinkList() { + $('#qwkUplinkList').html(`
${uiT('ui.common.loading', 'Loading...')}
`); + $.get('/api/qwk-uplinks') + .done(function(data) { + qwkUplinkCache = Array.isArray(data.uplinks) ? data.uplinks : []; + renderQwkUplinkList(); + }) + .fail(function() { + $('#qwkUplinkList').html(`
${uiT('ui.qwk.uplinks.load_failed', 'Failed to load QWK uplinks')}
`); + }); +} + +function renderQwkUplinkList() { + const container = $('#qwkUplinkList'); + if (!qwkUplinkCache.length) { + container.html(`
${uiT('ui.qwk.uplinks.none', 'No QWK uplinks configured')}
`); + return; + } + + let html = '
'; + qwkUplinkCache.forEach(function(uplink) { + html += ` +
+
+
+
${escapeHtml(uplink.name)}
+
${escapeHtml(uplink.host)}:${uplink.port} · ${escapeHtml(uplink.bbs_id || '')}
+ ${uplink.last_polled_at ? `
${uiT('ui.qwk.uplinks.last_polled', 'Last polled')}: ${escapeHtml(uplink.last_polled_at)}
` : ''} + ${uplink.last_error ? `
${escapeHtml(uplink.last_error)}
` : ''} +
+
+ + + +
+
+
+ `; + }); + html += '
'; + container.html(html); +} + +function editQwkUplink(id) { + $.get(`/api/qwk-uplinks/${id}`) + .done(function(data) { + const uplink = data.uplink; + currentQwkUplinkId = uplink.id; + $('#qwkUplinkFormTitle').text(uiT('ui.qwk.uplinks.edit_title', 'Edit QWK Uplink')); + $('#qwkUplinkName').val(uplink.name || ''); + $('#qwkUplinkBbsId').val(uplink.bbs_id || ''); + $('#qwkUplinkHost').val(uplink.host || ''); + $('#qwkUplinkPort').val(uplink.port || 21); + $('#qwkUplinkUsername').val(uplink.username || ''); + $('#qwkUplinkPassword').val(uplink.password_plain || ''); + $('#qwkUplinkPath').val(uplink.ftp_remote_path || '/'); + $('#qwkUplinkSchedule').val(uplink.poll_schedule || ''); + $('#qwkUplinkEnabled').prop('checked', !!uplink.enabled); + }) + .fail(function(xhr) { + $('#qwkUplinkAlert').text(apiError(xhr.responseJSON, uiT('ui.qwk.uplinks.load_failed', 'Failed to load QWK uplinks'))).removeClass('d-none'); + }); +} + +function saveQwkUplink() { + const payload = { + name: $('#qwkUplinkName').val(), + bbs_id: String($('#qwkUplinkBbsId').val() || '').toUpperCase(), + host: $('#qwkUplinkHost').val(), + port: parseInt($('#qwkUplinkPort').val(), 10) || 21, + username: $('#qwkUplinkUsername').val(), + password: $('#qwkUplinkPassword').val(), + ftp_remote_path: $('#qwkUplinkPath').val() || '/', + poll_schedule: $('#qwkUplinkSchedule').val() || '', + enabled: $('#qwkUplinkEnabled').is(':checked') + }; + + const url = currentQwkUplinkId ? `/api/qwk-uplinks/${currentQwkUplinkId}` : '/api/qwk-uplinks'; + const method = currentQwkUplinkId ? 'PUT' : 'POST'; + $.ajax({ + url: url, + method: method, + contentType: 'application/json', + data: JSON.stringify(payload), + success: function(response) { + $('#qwkUplinkAlert').addClass('d-none').text(''); + resetQwkUplinkForm(); + loadQwkUplinkList(); + const msg = window.getApiMessage ? window.getApiMessage(response, uiT('ui.qwk.uplinks.saved', 'QWK uplink saved')) : uiT('ui.qwk.uplinks.saved', 'QWK uplink saved'); + showSuccess(msg); + }, + error: function(xhr) { + $('#qwkUplinkAlert').text(apiError(xhr.responseJSON, uiT('ui.qwk.uplinks.save_failed', 'Failed to save QWK uplink'))).removeClass('d-none'); + } + }); +} + +function deleteQwkUplink(id) { + if (!window.confirm(uiT('ui.qwk.uplinks.delete_confirm', 'Delete this QWK uplink?'))) { + return; + } + + $.ajax({ + url: `/api/qwk-uplinks/${id}`, + method: 'DELETE', + success: function(response) { + loadQwkUplinkList(); + const msg = window.getApiMessage ? window.getApiMessage(response, uiT('ui.qwk.uplinks.deleted', 'QWK uplink deleted')) : uiT('ui.qwk.uplinks.deleted', 'QWK uplink deleted'); + showSuccess(msg); + }, + error: function(xhr) { + $('#qwkUplinkAlert').text(apiError(xhr.responseJSON, uiT('ui.qwk.uplinks.delete_failed', 'Failed to delete QWK uplink'))).removeClass('d-none'); + } + }); +} + +function pollQwkUplink(id) { + $.ajax({ + url: `/api/qwk-uplinks/${id}/poll`, + method: 'POST', + success: function(response) { + loadQwkUplinkList(); + const msg = window.getApiMessage ? window.getApiMessage(response, uiT('ui.qwk.uplinks.polled', 'QWK uplink polled')) : uiT('ui.qwk.uplinks.polled', 'QWK uplink polled'); + showSuccess(msg); + }, + error: function(xhr) { + $('#qwkUplinkAlert').text(apiError(xhr.responseJSON, uiT('ui.qwk.uplinks.poll_failed', 'Failed to poll QWK uplink'))).removeClass('d-none'); + } + }); +} + function escapeHtmlAttr(value) { return escapeHtml(value) .replace(/"/g, '"') From 498deae15332fe155e509f1e77e4c643cd4c3548 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 22 May 2026 20:41:41 -0700 Subject: [PATCH 03/19] WIP --- config/i18n/de/common.php | 32 +-- config/i18n/de/errors.php | 6 +- config/i18n/en/common.php | 32 +-- config/i18n/en/errors.php | 6 +- config/i18n/es/common.php | 32 +-- config/i18n/es/errors.php | 6 +- config/i18n/fr/common.php | 32 +-- config/i18n/fr/errors.php | 6 +- config/i18n/it/common.php | 32 +-- config/i18n/it/errors.php | 6 +- ...260523012838_qwk_mail_exchange_support.sql | 26 +- docs/API.md | 70 ++--- docs/CLI.md | 12 +- docs/DATA_MODEL.md | 31 ++- docs/QWK.md | 21 +- docs/UPGRADING_1.9.7.md | 9 - docs/UPGRADING_1.9.8.md | 10 +- public_html/sw.js | 2 +- routes/admin-routes.php | 1 - routes/api-routes.php | 50 ++-- scripts/qwk_poll.php | 8 +- src/EchoareaManager.php | 44 +++- src/{Qwk => Echomail}/GateProcessor.php | 34 +-- src/MessageHandler.php | 26 +- src/NetworkManager.php | 6 +- src/Qwk/QwkInbound.php | 34 ++- ...plinkManager.php => QwkMailboxManager.php} | 108 +++++--- src/Qwk/QwkOutbound.php | 26 +- src/Qwk/QwkPoller.php | 36 +-- src/Qwk/QwkSubscriptionManager.php | 63 +++-- src/Qwk/Transport/FtpTransport.php | 34 +-- src/Qwk/Transport/TransportInterface.php | 4 +- templates/admin/networks.twig | 19 ++ templates/echoareas.twig | 242 +++++++++--------- 34 files changed, 631 insertions(+), 475 deletions(-) rename src/{Qwk => Echomail}/GateProcessor.php (99%) rename src/Qwk/{QwkUplinkManager.php => QwkMailboxManager.php} (73%) diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index e414c19fe..d31267b40 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -4940,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', @@ -4959,31 +4961,31 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', - 'ui.qwk.uplinks.manage' => 'QWK Uplinks', - 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', - 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', - 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + '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.poll_schedule' => 'Poll Schedule', - 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', - 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', - 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', - 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', - 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + '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 uplink saved', - 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', - 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', - 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + '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.echoarea.subscriptions_title' => 'QWK Subscriptions', 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', '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' => 'Uplink', - 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.uplink_label' => 'Mailbox', + 'ui.qwk.echoarea.select_uplink' => 'Select mailbox', 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', 'ui.qwk.echoarea.conference_number' => 'Conference #', 'ui.qwk.echoarea.gate_target' => 'Target Area', diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index 566dcff8b..3f28d47d5 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -722,7 +722,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 uplink not found', - 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', - 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', + '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 a12ed5337..e17032d34 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -4962,6 +4962,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,31 +4983,31 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', - 'ui.qwk.uplinks.manage' => 'QWK Uplinks', - 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', - 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', - 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + '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.poll_schedule' => 'Poll Schedule', - 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', - 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', - 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', - 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', - 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + '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 uplink saved', - 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', - 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', - 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + '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.echoarea.subscriptions_title' => 'QWK Subscriptions', 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', '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' => 'Uplink', - 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.uplink_label' => 'Mailbox', + 'ui.qwk.echoarea.select_uplink' => 'Select mailbox', 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', 'ui.qwk.echoarea.conference_number' => 'Conference #', 'ui.qwk.echoarea.gate_target' => 'Target Area', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index 05d4dc19e..ad195c403 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -722,7 +722,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 uplink not found', - 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', - 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', + '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 d48bd4dd4..4429157a0 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -4928,6 +4928,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,31 +4949,31 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', - 'ui.qwk.uplinks.manage' => 'QWK Uplinks', - 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', - 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', - 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + '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.poll_schedule' => 'Poll Schedule', - 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', - 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', - 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', - 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', - 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + '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 uplink saved', - 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', - 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', - 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + '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.echoarea.subscriptions_title' => 'QWK Subscriptions', 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', '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' => 'Uplink', - 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.uplink_label' => 'Mailbox', + 'ui.qwk.echoarea.select_uplink' => 'Select mailbox', 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', 'ui.qwk.echoarea.conference_number' => 'Conference #', 'ui.qwk.echoarea.gate_target' => 'Target Area', diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 1c32a4bf5..b5339982a 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -720,7 +720,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 uplink not found', - 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', - 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', + '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 f77b5333b..44a2c6478 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -4867,6 +4867,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,31 +4888,31 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', - 'ui.qwk.uplinks.manage' => 'QWK Uplinks', - 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', - 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', - 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + '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.poll_schedule' => 'Poll Schedule', - 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', - 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', - 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', - 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', - 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + '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 uplink saved', - 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', - 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', - 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + '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.echoarea.subscriptions_title' => 'QWK Subscriptions', 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', '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' => 'Uplink', - 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.uplink_label' => 'Mailbox', + 'ui.qwk.echoarea.select_uplink' => 'Select mailbox', 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', 'ui.qwk.echoarea.conference_number' => 'Conference #', 'ui.qwk.echoarea.gate_target' => 'Target Area', diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index 8eb0df268..8f0c2c807 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -679,9 +679,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 uplink not found', - 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', - 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', + '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 dd2181aa3..b78518ce0 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -4925,6 +4925,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,31 +4946,31 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', - 'ui.qwk.uplinks.manage' => 'QWK Uplinks', - 'ui.qwk.uplinks.list_title' => 'Configured Uplinks', - 'ui.qwk.uplinks.add_title' => 'Add QWK Uplink', - 'ui.qwk.uplinks.edit_title' => 'Edit QWK Uplink', + '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.poll_schedule' => 'Poll Schedule', - 'ui.qwk.uplinks.load_failed' => 'Failed to load QWK uplinks', - 'ui.qwk.uplinks.save_failed' => 'Failed to save QWK uplink', - 'ui.qwk.uplinks.delete_failed' => 'Failed to delete QWK uplink', - 'ui.qwk.uplinks.poll_failed' => 'Failed to poll QWK uplink', - 'ui.qwk.uplinks.none' => 'No QWK uplinks configured', + '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 uplink saved', - 'ui.qwk.uplinks.deleted' => 'QWK uplink deleted', - 'ui.qwk.uplinks.delete_confirm' => 'Delete this QWK uplink?', - 'ui.qwk.uplinks.polled' => 'QWK uplink polled', + '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.echoarea.subscriptions_title' => 'QWK Subscriptions', 'ui.qwk.echoarea.subscriptions_help' => 'Map this echo area to one or more remote QWK conferences.', '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' => 'Uplink', - 'ui.qwk.echoarea.select_uplink' => 'Select uplink', + 'ui.qwk.echoarea.uplink_label' => 'Mailbox', + 'ui.qwk.echoarea.select_uplink' => 'Select mailbox', 'ui.qwk.echoarea.conference_tag' => 'Conference Tag', 'ui.qwk.echoarea.conference_number' => 'Conference #', 'ui.qwk.echoarea.gate_target' => 'Target Area', diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 50a69983f..b361bb68f 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -720,7 +720,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 uplink not found', - 'errors.qwk.invalid_uplink' => 'Invalid QWK uplink configuration', - 'errors.qwk.poll_failed' => 'Failed to poll QWK uplink', + '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 index 7ae423482..5b217a47b 100644 --- a/database/migrations/v20260523012838_qwk_mail_exchange_support.sql +++ b/database/migrations/v20260523012838_qwk_mail_exchange_support.sql @@ -8,8 +8,7 @@ -- ALTER TABLE users ADD COLUMN new_field VARCHAR(100); -- CREATE INDEX idx_new_field ON users(new_field); --- Create the QWK tables before wiring all echomail references. -CREATE TABLE IF NOT EXISTS qwk_uplinks ( +CREATE TABLE IF NOT EXISTS qwk_mailboxes ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, bbs_id VARCHAR(8) NOT NULL, @@ -29,21 +28,22 @@ CREATE TABLE IF NOT EXISTS qwk_uplinks ( CREATE TABLE IF NOT EXISTS echo_area_qwk_subscriptions ( id SERIAL PRIMARY KEY, echoarea_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, - uplink_id INTEGER NOT NULL REFERENCES qwk_uplinks(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_uplink_key UNIQUE (echoarea_id, uplink_id), - CONSTRAINT echo_area_qwk_subscriptions_uplink_conf_key UNIQUE (uplink_id, conference_number) + 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, - uplink_id INTEGER NOT NULL REFERENCES qwk_uplinks(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, uplink_id) + CONSTRAINT qwk_outbound_messages_unique UNIQUE (echomail_id, mailbox_id) ); CREATE TABLE IF NOT EXISTS echo_area_gates ( @@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS echo_area_gates ( ); ALTER TABLE echomail - ADD COLUMN IF NOT EXISTS qwk_uplink_id INTEGER REFERENCES qwk_uplinks(id) ON DELETE SET NULL, + 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); @@ -65,11 +65,11 @@ ALTER TABLE echomail CREATE INDEX IF NOT EXISTS idx_qwk_subscriptions_area ON echo_area_qwk_subscriptions (echoarea_id); -CREATE INDEX IF NOT EXISTS idx_qwk_subscriptions_uplink - ON echo_area_qwk_subscriptions (uplink_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 (uplink_id, sent_at); + 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); @@ -78,8 +78,8 @@ 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_uplink_id, qwk_conference_number, qwk_msg_number) - WHERE qwk_uplink_id IS NOT NULL + 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; diff --git a/docs/API.md b/docs/API.md index 0676bc8a4..609148867 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2071,7 +2071,7 @@ Array of echo areas **Requires authentication** Admin-only endpoint that returns the QWK-network configuration for one echo -area: mapped remote conferences, local gate rules, all configured QWK uplinks, +area: mapped remote conferences, local gate rules, all configured QWK mailboxes, and other available local areas that can be chosen as gate targets. **Path Parameters** @@ -2086,7 +2086,7 @@ and other available local areas that can be chosen as gate targets. |-------|------|-------------| | `subscriptions` | array | QWK conference mappings for this echo area | | `gates` | array | Gate definitions involving this echo area | -| `uplinks` | array | All configured QWK uplinks | +| `mailboxes` | array | All configured QWK mailboxes | | `available_areas` | array | Other local echo areas available as gate targets | **Error Responses** @@ -2115,7 +2115,7 @@ and local gate rules for an echo area. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `subscriptions` | array | No | Array of `{uplink_id, conference_tag, conference_number}` objects | +| `subscriptions` | array | No | Array of `{mailbox_id, conference_tag, conference_number}` objects | | `gates` | array | No | Array of `{target_area_id, bidirectional}` objects | **Response** _(JSON)_ @@ -5950,12 +5950,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-uplinks`](#get-apiqwk-uplinks) | Yes | List configured QWK uplinks for the admin UI. | -| `GET` | [`/api/qwk-uplinks/{id}`](#get-apiqwk-uplinksid) | Yes | Load one QWK uplink including its decrypted password for editing. | -| `POST` | [`/api/qwk-uplinks`](#post-apiqwk-uplinks) | Yes | Create a QWK uplink configuration. | -| `PUT` | [`/api/qwk-uplinks/{id}`](#put-apiqwk-uplinksid) | Yes | Update a QWK uplink configuration. | -| `DELETE` | [`/api/qwk-uplinks/{id}`](#delete-apiqwk-uplinksid) | Yes | Delete a QWK uplink configuration. | -| `POST` | [`/api/qwk-uplinks/{id}/poll`](#post-apiqwk-uplinksidpoll) | Yes | Poll one QWK uplink immediately. | +| `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` @@ -6158,18 +6158,18 @@ Search results --- -#### `GET /api/qwk-uplinks` +#### `GET /api/qwk-mailboxes` **Requires authentication** -Admin-only endpoint that lists all configured QWK uplinks for the management +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 | |-------|------|-------------| -| `uplinks` | array | QWK uplink records with status metadata | +| `mailboxes` | array | QWK mailbox records with status metadata | **Error Responses** @@ -6179,46 +6179,46 @@ UI. Passwords are not returned by this list endpoint. --- -#### `GET /api/qwk-uplinks/{id}` +#### `GET /api/qwk-mailboxes/{id}` **Requires authentication** -Admin-only endpoint that returns one QWK uplink for editing, including the +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 uplink ID | +| `id` | integer | QWK mailbox ID | **Response** _(JSON)_ | Field | Type | Description | |-------|------|-------------| -| `uplink` | object | Full QWK uplink record | -| `uplink.password_plain` | string | Decrypted password for edit-form reuse | +| `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 uplink not found | +| 404 | QWK mailbox not found | --- -#### `POST /api/qwk-uplinks` +#### `POST /api/qwk-mailboxes` **Requires authentication** -Admin-only endpoint that creates a new QWK uplink definition. +Admin-only endpoint that creates a new QWK mailbox definition. **Request Body** _(JSON)_ | Field | Type | Required | Description | |-------|------|----------|-------------| -| `name` | string | Yes | Friendly uplink name | +| `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`) | @@ -6233,7 +6233,7 @@ Admin-only endpoint that creates a new QWK uplink definition. | Field | Type | Description | |-------|------|-------------| | `success` | boolean | True on success | -| `id` | integer | New QWK uplink ID | +| `id` | integer | New QWK mailbox ID | | `message_code` | string | Localization key for UI success messaging | **Error Responses** @@ -6241,26 +6241,26 @@ Admin-only endpoint that creates a new QWK uplink definition. | Status | Description | |--------|-------------| | 403 | Admin privileges required | -| 400 | Invalid uplink payload or save failure | +| 400 | Invalid mailbox payload or save failure | --- -#### `PUT /api/qwk-uplinks/{id}` +#### `PUT /api/qwk-mailboxes/{id}` **Requires authentication** -Admin-only endpoint that updates an existing QWK uplink. If `password` is sent +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 uplink ID | +| `id` | integer | QWK mailbox ID | **Request Body** _(JSON)_ -Same shape as `POST /api/qwk-uplinks`. +Same shape as `POST /api/qwk-mailboxes`. **Response** _(JSON)_ @@ -6274,21 +6274,21 @@ Same shape as `POST /api/qwk-uplinks`. | Status | Description | |--------|-------------| | 403 | Admin privileges required | -| 400 | Invalid uplink payload or save failure | +| 400 | Invalid mailbox payload or save failure | --- -#### `DELETE /api/qwk-uplinks/{id}` +#### `DELETE /api/qwk-mailboxes/{id}` **Requires authentication** -Admin-only endpoint that deletes a configured QWK uplink. +Admin-only endpoint that deletes a configured QWK mailbox. **Path Parameters** | Name | Type | Description | |------|------|-------------| -| `id` | integer | QWK uplink ID | +| `id` | integer | QWK mailbox ID | **Response** _(JSON)_ @@ -6302,22 +6302,22 @@ Admin-only endpoint that deletes a configured QWK uplink. | Status | Description | |--------|-------------| | 403 | Admin privileges required | -| 404 | QWK uplink not found | +| 404 | QWK mailbox not found | --- -#### `POST /api/qwk-uplinks/{id}/poll` +#### `POST /api/qwk-mailboxes/{id}/poll` **Requires authentication** -Admin-only endpoint that immediately polls one QWK uplink, imports any inbound +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 uplink ID | +| `id` | integer | QWK mailbox ID | **Response** _(JSON)_ diff --git a/docs/CLI.md b/docs/CLI.md index 7ef064ad9..f1206a68a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -633,15 +633,15 @@ Options: ## QWK Mail Exchange Poll -Polls configured QWK uplinks, 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. +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. ```bash -# Poll all enabled QWK uplinks +# Poll all enabled QWK mailboxes php scripts/qwk_poll.php --all -# Poll one configured uplink by numeric ID +# Poll one configured QWK mailbox by numeric ID php scripts/qwk_poll.php 3 # Quiet mode for cron jobs @@ -649,7 +649,7 @@ php scripts/qwk_poll.php --all --quiet ``` Options: -- `--all` — Poll every enabled QWK uplink +- `--all` — Poll every enabled QWK mailbox - `--quiet` — Print only success/failure status - `--help` — Show usage - `--log-level=LVL`, `--log-file=FILE`, `--no-console` — Accepted for scheduler compatibility diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index fc8ba4e3b..40269e5a1 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -37,7 +37,7 @@ 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_uplink_id` / `qwk_conference_number` / `qwk_msg_number` | Present on inbound QWK-network messages for deduplication and reply mapping | +| `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. @@ -127,32 +127,40 @@ 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. -### `qwk_uplinks` +### `networks` -Admin-configured remote QWK systems that this BBS can poll like a client. +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 (up to 8 characters) | +| `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 uplink should be polled | +| `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 uplink. +Maps a local echo area to a conference number on a specific QWK mailbox. | Column | Notes | |--------|-------| | `echoarea_id` | FK → `echoareas.id` | -| `uplink_id` | FK → `qwk_uplinks.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. @@ -160,18 +168,19 @@ These rows drive both directions: inbound `.QWK` import routing and outbound ### `qwk_outbound_messages` Queue table for local echomail messages that still need to be exported to one -or more QWK uplinks. +or more QWK mailboxes. | Column | Notes | |--------|-------| | `echomail_id` | FK → `echomail.id` | -| `uplink_id` | FK → `qwk_uplinks.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 used by the QWK exchange feature. +Defines local cross-area mirroring rules for echoarea message gating across +multiple networks and import paths. | Column | Notes | |--------|-------| @@ -217,7 +226,7 @@ See [BinkStreamChannel.md](BinkStreamChannel.md) for the full architecture. | `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` | Per-user QWK offline mail reader state | -| `qwk_uplinks` / `echo_area_qwk_subscriptions` / `qwk_outbound_messages` / `echo_area_gates` | QWK network exchange configuration, queueing, and local gating | +| `qwk_mailboxes` / `echo_area_qwk_subscriptions` / `qwk_outbound_messages` / `echo_area_gates` | QWK network exchange configuration, 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/QWK.md b/docs/QWK.md index 739f59a50..5eb8d50f3 100644 --- a/docs/QWK.md +++ b/docs/QWK.md @@ -59,29 +59,32 @@ your replies back to the correct echo areas. ## QWK Network Exchange BinktermPHP can also act as a QWK client for another BBS. In this mode the -local system polls a remote QWK uplink, 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. +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. This is configured from the admin web interface: 1. Open **Admin → Echo Areas**. -2. Use **QWK Uplinks** to define the remote BBS ID, FTP host, credentials, and - remote path. +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. + local area to remote conference numbers on that mailbox. 4. Optionally add **Gates** to mirror imported or local traffic into other local areas. The transport/poll cycle is driven by `php scripts/qwk_poll.php --all` or by -polling a single uplink ID. +polling a single mailbox ID. Important behavior: -- Inbound deduplication uses `(qwk_uplink_id, qwk_conference_number, +- Inbound deduplication uses `(qwk_mailbox_id, qwk_conference_number, qwk_msg_number)`. +- Unknown conferences are auto-created as local placeholder areas using the + remote conference name as the description. The sysop can later move the area + into the correct network domain. - Outbound replies preserve QWK reply threading when the parent message came - from the same uplink and conference. + from the same mailbox and conference. - Gated local copies use `source_msgid` to prevent loops and duplicate mirrors. --- diff --git a/docs/UPGRADING_1.9.7.md b/docs/UPGRADING_1.9.7.md index a1fafc9d5..b97dac6e1 100644 --- a/docs/UPGRADING_1.9.7.md +++ b/docs/UPGRADING_1.9.7.md @@ -74,7 +74,6 @@ In the web interface, chat rooms now render inline media automatically, inline c - [Echo Areas .NA File Import](#echo-areas-na-file-import) - [CheeseNet Network Added](#cheesenet-network-added) - [New Echo Areas Load More](#new-echo-areas-load-more) - - [QWK FTP Root Upload](#qwk-ftp-root-upload) - [Auto Feed](#auto-feed-1) - [Reply Threading](#reply-threading) - [Developer Tooling](#developer-tooling-1) @@ -729,14 +728,6 @@ The feature becomes available as soon as the updated files are deployed. --- -### QWK FTP Root Upload - -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. - ---- - ## Developer Tooling The root `CLAUDE.md` file previously contained all project guidance in a single document. It has been refactored so that sections relevant only to a specific subdirectory now live in a `CLAUDE.md` file within that directory (auto-loaded by Claude Code when working there). Subdirectory files were added for `scripts/`, `telnet/`, `ssh/`, `templates/`, and `public_html/webdoors/`. diff --git a/docs/UPGRADING_1.9.8.md b/docs/UPGRADING_1.9.8.md index 18e3f31b7..5b087b5a9 100644 --- a/docs/UPGRADING_1.9.8.md +++ b/docs/UPGRADING_1.9.8.md @@ -6,6 +6,7 @@ Make sure you have a current backup of your database and files before upgrading. - [Summary of Changes](#summary-of-changes) - [Web Interface](#web-interface) +- [Developer / Infrastructure](#developer--infrastructure) - [Upgrade Instructions](#upgrade-instructions) - [From Git](#from-git) - [Using the Installer](#using-the-installer) @@ -16,6 +17,7 @@ Make sure you have a current backup of your database and files before upgrading. - The user-facing echoarea subscription manager at `/subscriptions` now uses a more compact filter layout modeled after `/echolist`, with network filtering and an option to show only interest groups that currently have message traffic. - Subscribing or unsubscribing from an echoarea in `/subscriptions` now updates in place instead of reloading the page, preserving the current scroll position and active search/filter state. +- The FTP daemon now accepts QWK reply packets uploaded directly to 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. ### Developer / Infrastructure @@ -24,8 +26,6 @@ Make sure you have a current backup of your database and files before upgrading. - `.env` may now include `DB_DRIVER=pgsql`. PostgreSQL is still the only supported value today. This setting exists to make future backend setup work easier to isolate if it is ever pursued. - A new developer reference document, `docs/PostgreSQLDependencies.md`, tracks intentional PostgreSQL-specific dependencies and where they currently live. ---- - ## Web Interface ### Subscription Manager @@ -42,6 +42,12 @@ 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. + ## Developer / Infrastructure ### Realtime Signaling Abstraction diff --git a/public_html/sw.js b/public_html/sw.js index 79e5b59e5..ecbd50a47 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v908'; +const CACHE_NAME = 'binkcache-v910'; // 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 a99e3895f..b57737b87 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -2555,7 +2555,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array ]); }); - SimpleRouter::get('/qwk-uplinks', function() { + SimpleRouter::get('/qwk-mailboxes', function() { $user = RouteHelper::requireAuth(); if (empty($user['is_admin'])) { http_response_code(403); @@ -2563,11 +2563,11 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array } header('Content-Type: application/json'); - $manager = new \BinktermPHP\Qwk\QwkUplinkManager(); - echo json_encode(['uplinks' => $manager->getAll()]); + $manager = new \BinktermPHP\Qwk\QwkMailboxManager(); + echo json_encode(['mailboxes' => $manager->getAll()]); }); - SimpleRouter::get('/qwk-uplinks/{id}', function($id) { + SimpleRouter::get('/qwk-mailboxes/{id}', function($id) { $user = RouteHelper::requireAuth(); if (empty($user['is_admin'])) { http_response_code(403); @@ -2575,17 +2575,17 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array } header('Content-Type: application/json'); - $manager = new \BinktermPHP\Qwk\QwkUplinkManager(); - $uplink = $manager->getById((int)$id, true); - if (!$uplink) { + $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 uplink not found', $user)); + apiError('errors.qwk.uplink_not_found', apiLocalizedText('errors.qwk.uplink_not_found', 'QWK mailbox not found', $user)); } - echo json_encode(['uplink' => $uplink]); + echo json_encode(['mailbox' => $mailbox]); })->where(['id' => '[0-9]+']); - SimpleRouter::post('/qwk-uplinks', function() { + SimpleRouter::post('/qwk-mailboxes', function() { $user = RouteHelper::requireAuth(); if (empty($user['is_admin'])) { http_response_code(403); @@ -2596,19 +2596,19 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $input = json_decode(file_get_contents('php://input'), true) ?? []; try { - $manager = new \BinktermPHP\Qwk\QwkUplinkManager(); + $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 uplink configuration', $user)); + 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-uplinks/{id}', function($id) { + SimpleRouter::put('/qwk-mailboxes/{id}', function($id) { $user = RouteHelper::requireAuth(); if (empty($user['is_admin'])) { http_response_code(403); @@ -2619,19 +2619,19 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $input = json_decode(file_get_contents('php://input'), true) ?? []; try { - $manager = new \BinktermPHP\Qwk\QwkUplinkManager(); + $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 uplink configuration', $user)); + 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-uplinks/{id}', function($id) { + SimpleRouter::delete('/qwk-mailboxes/{id}', function($id) { $user = RouteHelper::requireAuth(); if (empty($user['is_admin'])) { http_response_code(403); @@ -2639,15 +2639,15 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array } header('Content-Type: application/json'); - $manager = new \BinktermPHP\Qwk\QwkUplinkManager(); + $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 uplink not found', $user)); + 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-uplinks/{id}/poll', function($id) { + SimpleRouter::post('/qwk-mailboxes/{id}/poll', function($id) { $user = RouteHelper::requireAuth(); if (empty($user['is_admin'])) { http_response_code(403); @@ -2655,10 +2655,10 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array } header('Content-Type: application/json'); - $result = (new \BinktermPHP\Qwk\QwkPoller())->pollUplink((int)$id); + $result = (new \BinktermPHP\Qwk\QwkPoller())->pollMailbox((int)$id); if (empty($result['success'])) { http_response_code(400); - apiError('errors.qwk.poll_failed', apiLocalizedText('errors.qwk.poll_failed', 'Failed to poll QWK uplink', $user), 400, ['detail' => $result['error'] ?? null]); + 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)); @@ -2683,8 +2683,8 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array } $subscriptionManager = new \BinktermPHP\Qwk\QwkSubscriptionManager($db); - $gateProcessor = new \BinktermPHP\Qwk\GateProcessor($db); - $uplinkManager = new \BinktermPHP\Qwk\QwkUplinkManager($db); + $gateProcessor = new \BinktermPHP\Echomail\GateProcessor($db); + $mailboxManager = new \BinktermPHP\Qwk\QwkMailboxManager($db); $areasStmt = $db->prepare(" SELECT id, tag, domain, description @@ -2697,7 +2697,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array echo json_encode([ 'subscriptions' => $subscriptionManager->getSubscriptionsForArea($echoareaId), 'gates' => $gateProcessor->getGatesForArea($echoareaId), - 'uplinks' => $uplinkManager->getAll(), + 'mailboxes' => $mailboxManager->getAll(), 'available_areas' => $areasStmt->fetchAll(PDO::FETCH_ASSOC) ?: [], ]); })->where(['id' => '[0-9]+']); @@ -2727,7 +2727,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array try { $db->beginTransaction(); (new \BinktermPHP\Qwk\QwkSubscriptionManager($db))->replaceAreaSubscriptions($echoareaId, $subscriptions); - (new \BinktermPHP\Qwk\GateProcessor($db))->replaceAreaGates($echoareaId, $gates); + (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) { diff --git a/scripts/qwk_poll.php b/scripts/qwk_poll.php index d4913e8b4..e81725186 100755 --- a/scripts/qwk_poll.php +++ b/scripts/qwk_poll.php @@ -8,9 +8,9 @@ function qwkPollShowUsage(): void { - echo "Usage: php qwk_poll.php [options] [uplink-id]\n"; + echo "Usage: php qwk_poll.php [options] [mailbox-id]\n"; echo "Options:\n"; - echo " --all Poll all enabled QWK uplinks\n"; + echo " --all Poll all enabled QWK mailboxes\n"; echo " --log-level=LVL Accepted for compatibility\n"; echo " --log-file=FILE Accepted for compatibility\n"; echo " --no-console Accepted for compatibility\n"; @@ -83,8 +83,8 @@ function qwkPollParseArgs(array $argv): array exit(1); } - $uplinkId = (int)$positional[0]; - $result = $poller->pollUplink($uplinkId); + $mailboxId = (int)$positional[0]; + $result = $poller->pollMailbox($mailboxId); if ($quiet) { echo !empty($result['success']) ? "OK\n" : "FAIL\n"; exit(!empty($result['success']) ? 0 : 1); diff --git a/src/EchoareaManager.php b/src/EchoareaManager.php index 9cefe387f..9e9565ee5 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, [''])) { + $tag = $this->truncateTagWithSuffix($baseTag, $suffix); + $suffix++; + } + + return $this->createIfMissing([ + 'tag' => $tag, + 'description' => trim($conferenceName) !== '' ? trim($conferenceName) : ('QWK Conference ' . $conferenceNumber), + 'domain' => null, + 'is_local' => true, + 'is_active' => true, + 'is_sysop_only' => false, + 'gemini_public' => false, + ], ['']); } 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/Qwk/GateProcessor.php b/src/Echomail/GateProcessor.php similarity index 99% rename from src/Qwk/GateProcessor.php rename to src/Echomail/GateProcessor.php index bcf9a8a57..daa47e003 100644 --- a/src/Qwk/GateProcessor.php +++ b/src/Echomail/GateProcessor.php @@ -1,6 +1,6 @@ |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 */ @@ -142,22 +158,6 @@ private function resolveRoutes(int $echoareaId): array return array_values(array_unique($targets)); } - /** - * @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; - } - private function alreadyGated(int $targetAreaId, string $sourceMsgId): bool { $stmt = $this->db->prepare(" diff --git a/src/MessageHandler.php b/src/MessageHandler.php index f53c0ed4d..c6d640e49 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -1960,14 +1960,14 @@ public function importExternalEchomail(array $data): int 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_uplink_id, qwk_conference_number, + 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_uplink_id, :qwk_conference_number, + NULL, 'approved', :qwk_mailbox_id, :qwk_conference_number, :qwk_msg_number, :source_msgid ) RETURNING id @@ -1985,7 +1985,7 @@ public function importExternalEchomail(array $data): int $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_uplink_id', !empty($data['qwk_uplink_id']) ? (int)$data['qwk_uplink_id'] : null, !empty($data['qwk_uplink_id']) ? \PDO::PARAM_INT : \PDO::PARAM_NULL); + $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); @@ -2002,7 +2002,7 @@ public function importExternalEchomail(array $data): int $messageId, (string)$echoarea['tag'], $domain, - isset($data['exclude_qwk_uplink_id']) ? (int)$data['exclude_qwk_uplink_id'] : null, + isset($data['exclude_qwk_mailbox_id']) ? (int)$data['exclude_qwk_mailbox_id'] : null, !array_key_exists('apply_gates', $data) || !empty($data['apply_gates']) ); @@ -3376,16 +3376,16 @@ private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) } } - private function finalizeApprovedEchomailDelivery(int $messageId, string $echoareaTag, ?string $domain, ?int $excludeQwkUplinkId = null, bool $applyGates = true): void + 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, $excludeQwkUplinkId); + $this->queueQwkOutboundEchomail($messageId, $excludeQwkMailboxId); if ($applyGates) { - (new \BinktermPHP\Qwk\GateProcessor($this->db, $this))->processMessageById($messageId); + (new \BinktermPHP\Echomail\GateProcessor($this->db, $this))->processMessageById($messageId); } } - private function queueQwkOutboundEchomail(int $messageId, ?int $excludeQwkUplinkId = null): void + private function queueQwkOutboundEchomail(int $messageId, ?int $excludeQwkMailboxId = null): void { $stmt = $this->db->prepare("SELECT echoarea_id FROM echomail WHERE id = ?"); $stmt->execute([$messageId]); @@ -3400,17 +3400,17 @@ private function queueQwkOutboundEchomail(int $messageId, ?int $excludeQwkUplink } $insert = $this->db->prepare(" - INSERT INTO qwk_outbound_messages (echomail_id, uplink_id, queued_at) + INSERT INTO qwk_outbound_messages (echomail_id, mailbox_id, queued_at) VALUES (?, ?, NOW()) - ON CONFLICT (echomail_id, uplink_id) DO NOTHING + ON CONFLICT (echomail_id, mailbox_id) DO NOTHING "); foreach ($subscriptions as $subscription) { - $uplinkId = (int)$subscription['uplink_id']; - if ($excludeQwkUplinkId !== null && $uplinkId === $excludeQwkUplinkId) { + $mailboxId = (int)($subscription['mailbox_id'] ?? 0); + if ($excludeQwkMailboxId !== null && $mailboxId === $excludeQwkMailboxId) { continue; } - $insert->execute([$messageId, $uplinkId]); + $insert->execute([$messageId, $mailboxId]); } } 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/QwkInbound.php b/src/Qwk/QwkInbound.php index f729cca61..c3ef74ef9 100644 --- a/src/Qwk/QwkInbound.php +++ b/src/Qwk/QwkInbound.php @@ -28,11 +28,14 @@ public function __construct( /** * @return array{imported:int,skipped:int} */ - public function importPacket(int $uplinkId, string $zipPath): array + public function importPacket(int $mailboxId, string $zipPath): array { $parsed = $this->parser->parsePacket($zipPath); $imported = 0; $skipped = 0; + $conferenceMap = is_array($parsed['control']['conferences'] ?? null) + ? $parsed['control']['conferences'] + : []; foreach ($parsed['messages'] as $message) { if ($message->conferenceNumber <= 0) { @@ -40,19 +43,24 @@ public function importPacket(int $uplinkId, string $zipPath): array continue; } - $subscription = $this->subscriptions->getSubscriptionForConference($uplinkId, $message->conferenceNumber); + $conferenceTag = trim((string)($conferenceMap[$message->conferenceNumber] ?? '')); + $subscription = $this->subscriptions->getOrCreateSubscriptionForConference( + $mailboxId, + $message->conferenceNumber, + $conferenceTag + ); if ($subscription === null) { $skipped++; continue; } - if ($this->messageExists($uplinkId, $message->conferenceNumber, $message->messageNumber)) { + if ($this->messageExists($mailboxId, $message->conferenceNumber, $message->messageNumber)) { $skipped++; continue; } - $replyToId = $this->findReplyToId($uplinkId, $message->conferenceNumber, $message->replyToNumber); - $sourceMsgId = $message->sourceMsgId ?: sprintf('qwk:%d:%d:%d', $uplinkId, $message->conferenceNumber, $message->messageNumber); + $replyToId = $this->findReplyToId($mailboxId, $message->conferenceNumber, $message->replyToNumber); + $sourceMsgId = $message->sourceMsgId ?: sprintf('qwk:%d:%d:%d', $mailboxId, $message->conferenceNumber, $message->messageNumber); $newId = $this->messageHandler->importExternalEchomail([ 'echoarea_id' => (int)$subscription['echoarea_id'], @@ -63,10 +71,10 @@ public function importPacket(int $uplinkId, string $zipPath): array 'from_address' => null, 'reply_to_id' => $replyToId, 'source_msgid' => $sourceMsgId, - 'qwk_uplink_id' => $uplinkId, + 'qwk_mailbox_id' => $mailboxId, 'qwk_conference_number' => $message->conferenceNumber, 'qwk_msg_number' => $message->messageNumber, - 'exclude_qwk_uplink_id' => $uplinkId, + 'exclude_qwk_mailbox_id' => $mailboxId, 'apply_gates' => true, ]); @@ -80,19 +88,19 @@ public function importPacket(int $uplinkId, string $zipPath): array return ['imported' => $imported, 'skipped' => $skipped]; } - private function messageExists(int $uplinkId, int $conferenceNumber, int $messageNumber): bool + private function messageExists(int $mailboxId, int $conferenceNumber, int $messageNumber): bool { $stmt = $this->db->prepare(" SELECT 1 FROM echomail - WHERE qwk_uplink_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? + WHERE qwk_mailbox_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? LIMIT 1 "); - $stmt->execute([$uplinkId, $conferenceNumber, $messageNumber]); + $stmt->execute([$mailboxId, $conferenceNumber, $messageNumber]); return (bool)$stmt->fetchColumn(); } - private function findReplyToId(int $uplinkId, int $conferenceNumber, int $messageNumber): ?int + private function findReplyToId(int $mailboxId, int $conferenceNumber, int $messageNumber): ?int { if ($messageNumber <= 0) { return null; @@ -101,10 +109,10 @@ private function findReplyToId(int $uplinkId, int $conferenceNumber, int $messag $stmt = $this->db->prepare(" SELECT id FROM echomail - WHERE qwk_uplink_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? + WHERE qwk_mailbox_id = ? AND qwk_conference_number = ? AND qwk_msg_number = ? LIMIT 1 "); - $stmt->execute([$uplinkId, $conferenceNumber, $messageNumber]); + $stmt->execute([$mailboxId, $conferenceNumber, $messageNumber]); $id = $stmt->fetchColumn(); return $id ? (int)$id : null; } diff --git a/src/Qwk/QwkUplinkManager.php b/src/Qwk/QwkMailboxManager.php similarity index 73% rename from src/Qwk/QwkUplinkManager.php rename to src/Qwk/QwkMailboxManager.php index b4c718950..970d16f88 100644 --- a/src/Qwk/QwkUplinkManager.php +++ b/src/Qwk/QwkMailboxManager.php @@ -6,7 +6,7 @@ use BinktermPHP\SysK; use PDO; -class QwkUplinkManager +class QwkMailboxManager { private PDO $db; @@ -23,11 +23,12 @@ public function getAll(bool $includeSecrets = false): array $stmt = $this->db->query(" SELECT id, name, bbs_id, host, port, username, password, ftp_remote_path, poll_schedule, enabled, last_polled_at, last_error, created_at, updated_at - FROM qwk_uplinks + 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 { @@ -38,29 +39,23 @@ public function getAll(bool $includeSecrets = false): array return $rows; } - public function getById(int $id, bool $includeSecret = false): ?array + /** + * @param array $row + * @return array + */ + private function normalizeRow(array $row): array { - $stmt = $this->db->prepare(" - SELECT id, name, bbs_id, host, port, username, password, ftp_remote_path, - poll_schedule, enabled, last_polled_at, last_error, created_at, updated_at - FROM qwk_uplinks - WHERE id = ? - "); - $stmt->execute([$id]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$row) { - return null; - } - - if ($includeSecret) { - $row['password_plain'] = $this->decryptPassword((string)($row['password'] ?? '')); - } else { - unset($row['password']); - } - + $row['id'] = (int)($row['id'] ?? 0); + $row['port'] = (int)($row['port'] ?? 21); + $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 */ @@ -77,7 +72,7 @@ public function save(array $data, ?int $id = null): int $enabled = !empty($data['enabled']); if ($name === '' || $bbsId === '' || $host === '' || $username === '') { - throw new \InvalidArgumentException('Missing required uplink fields'); + throw new \InvalidArgumentException('Missing required mailbox fields'); } if ($port < 1 || $port > 65535) { @@ -85,13 +80,13 @@ public function save(array $data, ?int $id = null): int } if ($id === null && $password === '') { - throw new \InvalidArgumentException('Password is required for new QWK uplinks'); + 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 uplink not found'); + throw new \InvalidArgumentException('QWK mailbox not found'); } $password = (string)($existing['password_plain'] ?? ''); } @@ -100,32 +95,78 @@ public function save(array $data, ?int $id = null): int if ($id === null) { $stmt = $this->db->prepare(" - INSERT INTO qwk_uplinks + INSERT INTO qwk_mailboxes (name, bbs_id, host, port, username, password, ftp_remote_path, poll_schedule, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) RETURNING id "); - $stmt->execute([$name, $bbsId, $host, $port, $username, $encryptedPassword, $path, $schedule !== '' ? $schedule : null, $enabled ? 'true' : 'false']); + $stmt->execute([ + $name, + $bbsId, + $host, + $port, + $username, + $encryptedPassword, + $path !== '' ? $path : '/', + $schedule !== '' ? $schedule : null, + $enabled ? 'true' : 'false', + ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? (int)$row['id'] : 0; } $stmt = $this->db->prepare(" - UPDATE qwk_uplinks + UPDATE qwk_mailboxes SET name = ?, bbs_id = ?, host = ?, port = ?, username = ?, password = ?, ftp_remote_path = ?, poll_schedule = ?, enabled = ?, updated_at = NOW() WHERE id = ? "); - $stmt->execute([$name, $bbsId, $host, $port, $username, $encryptedPassword, $path, $schedule !== '' ? $schedule : null, $enabled ? 'true' : 'false', $id]); + $stmt->execute([ + $name, + $bbsId, + $host, + $port, + $username, + $encryptedPassword, + $path !== '' ? $path : '/', + $schedule !== '' ? $schedule : null, + $enabled ? 'true' : 'false', + $id, + ]); if ($stmt->rowCount() === 0) { - throw new \InvalidArgumentException('QWK uplink not found'); + 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, + 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_uplinks WHERE id = ?"); + $stmt = $this->db->prepare("DELETE FROM qwk_mailboxes WHERE id = ?"); $stmt->execute([$id]); return $stmt->rowCount() > 0; } @@ -133,7 +174,7 @@ public function delete(int $id): bool public function markPollResult(int $id, ?string $error = null): void { $stmt = $this->db->prepare(" - UPDATE qwk_uplinks + UPDATE qwk_mailboxes SET last_polled_at = NOW(), last_error = ?, updated_at = NOW() @@ -141,9 +182,4 @@ public function markPollResult(int $id, ?string $error = null): void "); $stmt->execute([$error, $id]); } - - public function decryptPassword(string $encrypted): string - { - return SysK::decrypt($encrypted); - } } diff --git a/src/Qwk/QwkOutbound.php b/src/Qwk/QwkOutbound.php index 5746f4501..637704e5b 100644 --- a/src/Qwk/QwkOutbound.php +++ b/src/Qwk/QwkOutbound.php @@ -16,9 +16,9 @@ public function __construct(?PDO $db = null, ?RepPacketBuilder $builder = null) $this->builder = $builder ?? new RepPacketBuilder(); } - public function buildPendingRepPacket(array $uplink): ?string + public function buildPendingRepPacket(array $mailbox): ?string { - $rows = $this->getPendingMessages((int)$uplink['id']); + $rows = $this->getPendingMessages((int)$mailbox['id']); if ($rows === []) { return null; } @@ -26,8 +26,8 @@ public function buildPendingRepPacket(array $uplink): ?string $messages = []; foreach ($rows as $row) { $replyToNum = 0; - if (!empty($row['reply_qwk_uplink_id']) - && (int)$row['reply_qwk_uplink_id'] === (int)$uplink['id'] + 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']; @@ -43,23 +43,23 @@ public function buildPendingRepPacket(array $uplink): ?string ]; } - return $this->builder->build((string)$uplink['bbs_id'], $messages); + return $this->builder->build((string)$mailbox['bbs_id'], $messages); } - public function markUploaded(int $uplinkId): void + public function markUploaded(int $mailboxId): void { $stmt = $this->db->prepare(" UPDATE qwk_outbound_messages SET sent_at = NOW() - WHERE uplink_id = ? AND sent_at IS NULL + WHERE mailbox_id = ? AND sent_at IS NULL "); - $stmt->execute([$uplinkId]); + $stmt->execute([$mailboxId]); } /** * @return array> */ - private function getPendingMessages(int $uplinkId): array + private function getPendingMessages(int $mailboxId): array { $stmt = $this->db->prepare(" SELECT qom.id AS queue_id, @@ -69,20 +69,20 @@ private function getPendingMessages(int $uplinkId): array em.subject, em.message_text, s.conference_number, - parent.qwk_uplink_id AS reply_qwk_uplink_id, + 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 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.uplink_id = qom.uplink_id + AND s.mailbox_id = qom.mailbox_id LEFT JOIN echomail parent ON parent.id = em.reply_to_id - WHERE qom.uplink_id = ? + WHERE qom.mailbox_id = ? AND qom.sent_at IS NULL ORDER BY qom.id ASC "); - $stmt->execute([$uplinkId]); + $stmt->execute([$mailboxId]); return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } } diff --git a/src/Qwk/QwkPoller.php b/src/Qwk/QwkPoller.php index 0356ccaec..1a5be3bf6 100644 --- a/src/Qwk/QwkPoller.php +++ b/src/Qwk/QwkPoller.php @@ -7,31 +7,31 @@ class QwkPoller { - private QwkUplinkManager $uplinks; + private QwkMailboxManager $mailboxes; private QwkInbound $inbound; private QwkOutbound $outbound; private TransportInterface $transport; public function __construct( - ?QwkUplinkManager $uplinks = null, + ?QwkMailboxManager $mailboxes = null, ?QwkInbound $inbound = null, ?QwkOutbound $outbound = null, ?TransportInterface $transport = null ) { - $this->uplinks = $uplinks ?? new QwkUplinkManager(); + $this->mailboxes = $mailboxes ?? new QwkMailboxManager(); $this->inbound = $inbound ?? new QwkInbound(); $this->outbound = $outbound ?? new QwkOutbound(); - $this->transport = $transport ?? new FtpTransport($this->uplinks); + $this->transport = $transport ?? new FtpTransport($this->mailboxes); } /** * @return array */ - public function pollUplink(int $uplinkId): array + public function pollMailbox(int $mailboxId): array { - $uplink = $this->uplinks->getById($uplinkId, true); - if ($uplink === null) { - throw new \InvalidArgumentException('QWK uplink not found'); + $mailbox = $this->mailboxes->getById($mailboxId, true); + if ($mailbox === null) { + throw new \InvalidArgumentException('QWK mailbox not found'); } $downloadedPath = null; @@ -39,25 +39,25 @@ public function pollUplink(int $uplinkId): array try { $stats = ['imported' => 0, 'skipped' => 0, 'uploaded' => false]; - $downloadedPath = $this->transport->downloadPacket($uplink); + $downloadedPath = $this->transport->downloadPacket($mailbox); if ($downloadedPath !== null) { - $importStats = $this->inbound->importPacket($uplinkId, $downloadedPath); + $importStats = $this->inbound->importPacket($mailboxId, $downloadedPath); $stats['imported'] = $importStats['imported']; $stats['skipped'] = $importStats['skipped']; } - $repPath = $this->outbound->buildPendingRepPacket($uplink); + $repPath = $this->outbound->buildPendingRepPacket($mailbox); if ($repPath !== null) { - $stats['uploaded'] = $this->transport->uploadPacket($uplink, $repPath); + $stats['uploaded'] = $this->transport->uploadPacket($mailbox, $repPath); if ($stats['uploaded']) { - $this->outbound->markUploaded($uplinkId); + $this->outbound->markUploaded($mailboxId); } } - $this->uplinks->markPollResult($uplinkId, null); + $this->mailboxes->markPollResult($mailboxId, null); return array_merge(['success' => true], $stats); } catch (\Throwable $e) { - $this->uplinks->markPollResult($uplinkId, $e->getMessage()); + $this->mailboxes->markPollResult($mailboxId, $e->getMessage()); return ['success' => false, 'error' => $e->getMessage()]; } finally { if ($downloadedPath !== null && is_file($downloadedPath)) { @@ -75,11 +75,11 @@ public function pollUplink(int $uplinkId): array public function pollAllEnabled(): array { $results = []; - foreach ($this->uplinks->getAll() as $uplink) { - if (empty($uplink['enabled'])) { + foreach ($this->mailboxes->getAll() as $mailbox) { + if (empty($mailbox['enabled'])) { continue; } - $results[(string)$uplink['name']] = $this->pollUplink((int)$uplink['id']); + $results[(string)$mailbox['name']] = $this->pollMailbox((int)$mailbox['id']); } return $results; } diff --git a/src/Qwk/QwkSubscriptionManager.php b/src/Qwk/QwkSubscriptionManager.php index 98f5d40d0..b403c0f4c 100644 --- a/src/Qwk/QwkSubscriptionManager.php +++ b/src/Qwk/QwkSubscriptionManager.php @@ -3,15 +3,18 @@ namespace BinktermPHP\Qwk; use BinktermPHP\Database; +use BinktermPHP\EchoareaManager; use PDO; class QwkSubscriptionManager { private PDO $db; + private EchoareaManager $echoareaManager; - public function __construct(?PDO $db = null) + public function __construct(?PDO $db = null, ?EchoareaManager $echoareaManager = null) { $this->db = $db ?? Database::getInstance()->getPdo(); + $this->echoareaManager = $echoareaManager ?? new EchoareaManager($this->db); } /** @@ -20,12 +23,12 @@ public function __construct(?PDO $db = null) public function getSubscriptionsForArea(int $echoareaId): array { $stmt = $this->db->prepare(" - SELECT s.id, s.echoarea_id, s.uplink_id, s.conference_tag, s.conference_number, - u.name AS uplink_name, u.bbs_id AS uplink_bbs_id, u.enabled AS uplink_enabled + 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_uplinks u ON u.id = s.uplink_id + JOIN qwk_mailboxes m ON m.id = s.mailbox_id WHERE s.echoarea_id = ? - ORDER BY LOWER(u.name), s.conference_number + ORDER BY LOWER(m.name), s.conference_number "); $stmt->execute([$echoareaId]); return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; @@ -34,33 +37,61 @@ public function getSubscriptionsForArea(int $echoareaId): array /** * @return array> */ - public function getSubscriptionsForUplink(int $uplinkId): 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.uplink_id = ? + WHERE s.mailbox_id = ? ORDER BY s.conference_number, e.id "); - $stmt->execute([$uplinkId]); + $stmt->execute([$mailboxId]); return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } - public function getSubscriptionForConference(int $uplinkId, int $conferenceNumber): ?array + 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.uplink_id = ? AND s.conference_number = ? + WHERE s.mailbox_id = ? AND s.conference_number = ? LIMIT 1 "); - $stmt->execute([$uplinkId, $conferenceNumber]); + $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 */ @@ -75,19 +106,19 @@ public function replaceAreaSubscriptions(int $echoareaId, array $subscriptions): $insertStmt = $this->db->prepare(" INSERT INTO echo_area_qwk_subscriptions - (echoarea_id, uplink_id, conference_tag, conference_number, created_at) - VALUES (?, ?, ?, ?, NOW()) + (echoarea_id, mailbox_id, conference_tag, conference_number, auto_created, created_at) + VALUES (?, ?, ?, ?, 'false', NOW()) "); foreach ($subscriptions as $subscription) { - $uplinkId = (int)($subscription['uplink_id'] ?? 0); + $mailboxId = (int)($subscription['mailbox_id'] ?? 0); $conferenceNumber = (int)($subscription['conference_number'] ?? 0); $conferenceTag = trim((string)($subscription['conference_tag'] ?? '')); - if ($uplinkId <= 0 || $conferenceNumber < 0 || $conferenceTag === '') { + if ($mailboxId <= 0 || $conferenceNumber < 0 || $conferenceTag === '') { throw new \InvalidArgumentException('Invalid QWK subscription payload'); } - $insertStmt->execute([$echoareaId, $uplinkId, $conferenceTag, $conferenceNumber]); + $insertStmt->execute([$echoareaId, $mailboxId, $conferenceTag, $conferenceNumber]); } } } diff --git a/src/Qwk/Transport/FtpTransport.php b/src/Qwk/Transport/FtpTransport.php index dd38cd708..232cb724c 100644 --- a/src/Qwk/Transport/FtpTransport.php +++ b/src/Qwk/Transport/FtpTransport.php @@ -2,22 +2,22 @@ namespace BinktermPHP\Qwk\Transport; -use BinktermPHP\Qwk\QwkUplinkManager; +use BinktermPHP\Qwk\QwkMailboxManager; class FtpTransport implements TransportInterface { - private QwkUplinkManager $uplinkManager; + private QwkMailboxManager $mailboxManager; - public function __construct(?QwkUplinkManager $uplinkManager = null) + public function __construct(?QwkMailboxManager $mailboxManager = null) { - $this->uplinkManager = $uplinkManager ?? new QwkUplinkManager(); + $this->mailboxManager = $mailboxManager ?? new QwkMailboxManager(); } - public function downloadPacket(array $uplink): ?string + public function downloadPacket(array $mailbox): ?string { - $conn = $this->connect($uplink); + $conn = $this->connect($mailbox); try { - $remotePath = $this->buildRemotePath($uplink, strtoupper((string)$uplink['bbs_id']) . '.QWK'); + $remotePath = $this->buildRemotePath($mailbox, strtoupper((string)$mailbox['bbs_id']) . '.QWK'); $tmpPath = tempnam(sys_get_temp_dir(), 'qwkdl_'); if ($tmpPath === false) { throw new \RuntimeException('Failed to allocate temporary download path'); @@ -34,11 +34,11 @@ public function downloadPacket(array $uplink): ?string } } - public function uploadPacket(array $uplink, string $localPacketPath): bool + public function uploadPacket(array $mailbox, string $localPacketPath): bool { - $conn = $this->connect($uplink); + $conn = $this->connect($mailbox); try { - $remotePath = $this->buildRemotePath($uplink, strtoupper((string)$uplink['bbs_id']) . '.REP'); + $remotePath = $this->buildRemotePath($mailbox, strtoupper((string)$mailbox['bbs_id']) . '.REP'); return (bool)@ftp_put($conn, $remotePath, $localPacketPath, FTP_BINARY); } finally { @ftp_close($conn); @@ -48,21 +48,21 @@ public function uploadPacket(array $uplink, string $localPacketPath): bool /** * @return resource */ - private function connect(array $uplink) + private function connect(array $mailbox) { if (!function_exists('ftp_connect')) { throw new \RuntimeException('PHP FTP extension is not available'); } - $host = (string)$uplink['host']; - $port = (int)($uplink['port'] ?? 21); + $host = (string)$mailbox['host']; + $port = (int)($mailbox['port'] ?? 21); $conn = @ftp_connect($host, $port, 20); if ($conn === false) { throw new \RuntimeException('Failed to connect to FTP host'); } - $password = $this->uplinkManager->decryptPassword((string)($uplink['password'] ?? '')); - if (!@ftp_login($conn, (string)$uplink['username'], $password)) { + $password = $this->mailboxManager->decryptPassword((string)($mailbox['password'] ?? '')); + if (!@ftp_login($conn, (string)$mailbox['username'], $password)) { @ftp_close($conn); throw new \RuntimeException('FTP login failed'); } @@ -71,9 +71,9 @@ private function connect(array $uplink) return $conn; } - private function buildRemotePath(array $uplink, string $filename): string + private function buildRemotePath(array $mailbox, string $filename): string { - $base = trim((string)($uplink['ftp_remote_path'] ?? '/')); + $base = trim((string)($mailbox['ftp_remote_path'] ?? '/')); if ($base === '' || $base === '.') { return $filename; } diff --git a/src/Qwk/Transport/TransportInterface.php b/src/Qwk/Transport/TransportInterface.php index 82ee31a50..b3da264a4 100644 --- a/src/Qwk/Transport/TransportInterface.php +++ b/src/Qwk/Transport/TransportInterface.php @@ -4,7 +4,7 @@ interface TransportInterface { - public function downloadPacket(array $uplink): ?string; + public function downloadPacket(array $mailbox): ?string; - public function uploadPacket(array $uplink, string $localPacketPath): bool; + public function uploadPacket(array $mailbox, string $localPacketPath): bool; } diff --git a/templates/admin/networks.twig b/templates/admin/networks.twig index f148a7c71..cc442da66 100644 --- a/templates/admin/networks.twig +++ b/templates/admin/networks.twig @@ -57,6 +57,13 @@ +
+ + +
@@ -136,6 +143,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) { @@ -220,10 +228,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 +251,7 @@ 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('networkName').value = network ? network.name : ''; document.getElementById('networkDescription').value = network ? (network.description || '') : ''; document.getElementById('networkWebsite').value = network ? (network.website || '') : ''; @@ -261,6 +279,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, diff --git a/templates/echoareas.twig b/templates/echoareas.twig index e4ab62cb5..e97a6ed2c 100644 --- a/templates/echoareas.twig +++ b/templates/echoareas.twig @@ -13,7 +13,7 @@ {{ t('ui.common.import', {}, locale, ['common']) }} - @@ -326,7 +326,7 @@
-