Skip to content

Claudesbbs to main#372

Open
awehttam wants to merge 52 commits into
mainfrom
claudesbbs
Open

Claudesbbs to main#372
awehttam wants to merge 52 commits into
mainfrom
claudesbbs

Conversation

@awehttam

@awehttam awehttam commented Jun 2, 2026

Copy link
Copy Markdown
Owner

No description provided.

awehttam and others added 30 commits May 21, 2026 22:57
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 <noreply@anthropic.com>
- Expose QWK download file at / in addition to /qwk/download/ so FTP
  clients see it without navigating subdirectories
- Pre-build QWK packet eagerly at FTP login so directory listings report
  the actual file size rather than 0
- Declare ext-ftp requirement in composer.json (used by FtpTransport for
  outbound QWK polling); document it in docs/INSTALL.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This reverts commit 6078229, reversing
changes made to e3fbc1e.
- Fill in field tables for 13 endpoints that had description-only
  response sections (no field table): binkp/status, kept-packets,
  logs, logs/search, fileareas/stats, media/raw, echomail shares,
  and all four subscriptions endpoints
- Correct binary responses (media/raw, kept-packets/bundle/download)
  to Response (binary) with response header tables instead of JSON
- Fix pagination structure on GET /api/messages/netmail and
  GET /api/messages/echomail: fields were documented at the top level
  but the code returns them inside a pagination sub-object
- Document the full netmail object shape (17 fields) on the list
  and conversation endpoints
- Document the full echomail object shape (19 fields) on the list
  and conversation endpoints
- Add unreadCount and threaded envelope fields to both conversation
  endpoints
- Note replyto_address/replyto_name are absent from echomail
  conversation responses (kludge parsing is skipped in that path)
- Fix auth labels on subscriptions endpoints (Public -> Requires
  authentication)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Document all remaining unexpanded object/array response fields:
- GET /api/i18n/catalog: clarify catalogs is namespace-keyed map
- GET /api/messages/netmail/{id}: document attachments[] sub-fields
- GET /api/messages/echomail/stats: fix areas field (integer not array),
  add full response table with filter_counts sub-fields
- POST /api/messages/echomail/ignore-rules: add message_params.sender_name
- GET /api/messages/drafts: document drafts[] sub-fields from drafts table
- GET /api/messages/drafts/{id}: document draft{} sub-fields
- GET /api/messages/templates: document templates[] sub-fields
- GET /api/messages/templates/{id}: document template{} sub-fields
- POST /api/qwk/upload: change errors type to 'array of strings'
- GET /api/user/terminal-settings: document settings{} sub-fields
- GET /api/user/terminal-mail-state: document all 8 settings{} sub-fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CLAUDE.md: expand API route change rule to require sub-field rows
  for every object/array field; name the failure condition explicitly
  and link to DEVELOPER_GUIDE for full spec
- DEVELOPER_GUIDE.md: add Response table format section under API
  Documentation Generator with four named rules, notation examples,
  and a complete sample response table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@awehttam awehttam changed the title Claudesbbs Claudesbbs to main Jun 2, 2026
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds PGP key management end-to-end (DB schema, PHP services, API routes, UI templates, JS helpers), introduces realtime listener/publisher abstractions, a database platform layer, configurable pipe-code parser mode across web/terminal, address-book PGP contact integration, new CLI/tooling and service worker updates, and extensive documentation.

Changes

PGP, Realtime, DB Platform, and Parser Mode

Layer / File(s) Summary
Complete functional change set
multiple files across src/*, routes/*, templates/*, public_html/js/*, database/migrations/*, scripts/*, docs/*
Implements full PGP storage/services/APIs/UI/JS, realtime abstractions, DB platform bootstrap, configurable pipe-code parser mode, address-book PGP integration, CLI/util updates, and documentation additions.

Sequence Diagram(s)

sequenceDiagram
  participant BinkStream
  participant PostgresEventPublisher
  BinkStream->>PostgresEventPublisher: publish('binkstream', eventId)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

A rabbit taps keys with a cryptic delight,
PGP burrows bloom—keys gleam in the night.
Pipes parse in modes, strict to loose flow,
Realtime thumps notify, events leap to go.
Hop, sign, encrypt—messages softly glow. 🐇🔐✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claudesbbs
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch claudesbbs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (14)
config/i18n/de/common.php-5007-5007 (1)

5007-5007: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix German grammar in upload progress text.

Line 5007 currently reads awkwardly (Oeffentlichen Schluessel wird hochgeladen...). Please switch to grammatically correct phrasing (e.g., Oeffentlicher Schluessel wird hochgeladen...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/de/common.php` at line 5007, Update the translation value for the
key 'ui.settings.pgp.uploading' to correct German grammar by replacing
"Oeffentlichen Schluessel wird hochgeladen..." with the grammatically correct
phrase, e.g. "Oeffentlicher Schluessel wird hochgeladen..."; ensure only the
string value is changed and the key name 'ui.settings.pgp.uploading' remains
unchanged.
config/i18n/de/errors.php-92-92 (1)

92-92: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use literal umlaut character instead of digraph.

Line 92 uses "Schluessel" instead of "Schlüssel". As per coding guidelines, German i18n catalogs should use literal accented characters (ü, ö, ä, ß) rather than digraphs (ue, oe, ae, ss). The rest of the file consistently uses literal umlauts.

🔧 Proposed fix
-    'errors.pgp.private_key_required' => 'Zum Signieren ist ein verwalteter privater Schluessel erforderlich.',
+    'errors.pgp.private_key_required' => 'Zum Signieren ist ein verwalteter privater Schlüssel erforderlich.',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/de/errors.php` at line 92, Replace the digraph "Schluessel" in
the translation value for the key 'errors.pgp.private_key_required' with the
literal umlaut form "Schlüssel" so the string reads "Zum Signieren ist ein
verwalteter privater Schlüssel erforderlich."; update that exact value to use
the literal "ü" to match the rest of the German i18n catalog.
config/i18n/de/errors.php-729-741 (1)

729-741: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace all digraphs with literal umlaut characters.

Lines 729-741 use German digraphs (ue, oe, ae) instead of literal umlauts (ü, ö, ä) throughout. This violates the coding guideline for accented catalogs and is inconsistent with the rest of this file, which uses literal umlauts in strings like "ungültig", "für", "können", etc.

🔧 Proposed fix
-    'errors.pgp.load_failed' => 'PGP-Schluessel konnten nicht geladen werden.',
-    'errors.pgp.public_key_required' => 'Ein oeffentlicher Schluessel ist erforderlich.',
-    'errors.pgp.invalid_key' => 'Ungueltiger PGP-Schluessel.',
-    'errors.pgp.save_failed' => 'PGP-Schluessel konnte nicht gespeichert werden.',
-    'errors.pgp.invalid_keypair' => 'Ungueltiges PGP-Schluesselpaar.',
-    'errors.pgp.key_not_found' => 'PGP-Schluessel nicht gefunden.',
-    'errors.pgp.private_key_not_found' => 'Privater Schluessel nicht gefunden.',
-    'errors.pgp.delete_failed' => 'PGP-Schluessel konnte nicht geloescht werden.',
+    'errors.pgp.load_failed' => 'PGP-Schlüssel konnten nicht geladen werden.',
+    'errors.pgp.public_key_required' => 'Ein öffentlicher Schlüssel ist erforderlich.',
+    'errors.pgp.invalid_key' => 'Ungültiger PGP-Schlüssel.',
+    'errors.pgp.save_failed' => 'PGP-Schlüssel konnte nicht gespeichert werden.',
+    'errors.pgp.invalid_keypair' => 'Ungültiges PGP-Schlüsselpaar.',
+    'errors.pgp.key_not_found' => 'PGP-Schlüssel nicht gefunden.',
+    'errors.pgp.private_key_not_found' => 'Privater Schlüssel nicht gefunden.',
+    'errors.pgp.delete_failed' => 'PGP-Schlüssel konnte nicht gelöscht werden.',
     'errors.pgp.disabled' => 'PGP ist auf diesem System deaktiviert.',
-    'errors.pgp.managed_disabled' => 'Die Erzeugung verwalteter PGP-Schluessel ist auf diesem System deaktiviert.',
-    'errors.pgp.passphrase_too_short' => 'Verwenden Sie eine laengere PGP-Passphrase.',
-    'errors.pgp.passphrase_mismatch' => 'Die Passphrase-Bestaetigung stimmt nicht ueberein.',
-    'errors.pgp.generation_failed' => 'BBS-verwalteter PGP-Schluessel konnte nicht erzeugt werden.',
+    'errors.pgp.managed_disabled' => 'Die Erzeugung verwalteter PGP-Schlüssel ist auf diesem System deaktiviert.',
+    'errors.pgp.passphrase_too_short' => 'Verwenden Sie eine längere PGP-Passphrase.',
+    'errors.pgp.passphrase_mismatch' => 'Die Passphrase-Bestätigung stimmt nicht überein.',
+    'errors.pgp.generation_failed' => 'BBS-verwalteter PGP-Schlüssel konnte nicht erzeugt werden.',

As per coding guidelines, when editing German or other accented catalogs, use literal characters only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/de/errors.php` around lines 729 - 741, Edit the German error
strings for the PGP keys block (e.g., keys like 'errors.pgp.load_failed',
'errors.pgp.public_key_required', 'errors.pgp.invalid_key',
'errors.pgp.save_failed', 'errors.pgp.invalid_keypair',
'errors.pgp.key_not_found', 'errors.pgp.private_key_not_found',
'errors.pgp.delete_failed', 'errors.pgp.disabled',
'errors.pgp.managed_disabled', 'errors.pgp.passphrase_too_short',
'errors.pgp.passphrase_mismatch', 'errors.pgp.generation_failed') and replace
all digraphs (ae, oe, ue, ss where appropriate) with the corresponding literal
German characters (ä, ö, ü, ß) so the messages use proper umlauts and match the
rest of the file.
config/i18n/es/errors.php-728-738 (1)

728-738: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix missing Spanish accents in error messages.

Several Spanish error messages are missing required accent marks (tildes). According to the coding guidelines, accented catalogs must use literal accented characters.

✏️ Proposed fix
-    'errors.pgp.public_key_required' => 'Se requiere una clave publica.',
+    'errors.pgp.public_key_required' => 'Se requiere una clave pública.',
-    'errors.pgp.invalid_key' => 'Clave publica PGP invalida.',
+    'errors.pgp.invalid_key' => 'Clave pública PGP inválida.',
-    'errors.pgp.invalid_keypair' => 'Par de claves PGP invalido.',
+    'errors.pgp.invalid_keypair' => 'Par de claves PGP inválido.',
-    'errors.pgp.key_not_found' => 'No se encontro la clave PGP.',
+    'errors.pgp.key_not_found' => 'No se encontró la clave PGP.',
-    'errors.pgp.private_key_not_found' => 'No se encontro la clave privada.',
+    'errors.pgp.private_key_not_found' => 'No se encontró la clave privada.',
-    'errors.pgp.disabled' => 'PGP esta desactivado en este sistema.',
+    'errors.pgp.disabled' => 'PGP está desactivado en este sistema.',
-    'errors.pgp.managed_disabled' => 'La generacion de claves PGP gestionadas esta desactivada en este sistema.',
+    'errors.pgp.managed_disabled' => 'La generación de claves PGP gestionadas está desactivada en este sistema.',
-    'errors.pgp.passphrase_too_short' => 'Use una frase de acceso PGP mas larga.',
+    'errors.pgp.passphrase_too_short' => 'Use una frase de acceso PGP más larga.',
-    'errors.pgp.passphrase_mismatch' => 'La confirmacion de la frase de acceso no coincide.',
+    'errors.pgp.passphrase_mismatch' => 'La confirmación de la frase de acceso no coincide.',

As per coding guidelines: "When editing French or other accented catalogs, use literal characters (e.g., 'é', 'à') only."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/es/errors.php` around lines 728 - 738, The Spanish error strings
for the PGP keys are missing proper accents; update the translation values for
the listed keys (errors.pgp.public_key_required, errors.pgp.invalid_key,
errors.pgp.save_failed, errors.pgp.invalid_keypair, errors.pgp.key_not_found,
errors.pgp.private_key_not_found, errors.pgp.delete_failed, errors.pgp.disabled,
errors.pgp.managed_disabled, errors.pgp.passphrase_too_short,
errors.pgp.passphrase_mismatch) to use literal accented characters (e.g., "clave
pública", "inválida", "no se encontró", "está", "generación", "más",
"confirmación") so each message uses correct Spanish accents per the catalog
guidelines.
config/i18n/it/common.php-1814-1814 (1)

1814-1814: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix Italian accenting in user-facing copy.

Line 1814 should use è in “Quando è abilitato” to avoid awkward/incorrect Italian in the admin UI.

Suggested fix
-    'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'Quando e abilitato, gli utenti possono generare coppie di chiavi PGP nel browser e salvare su questo server il blob cifrato della chiave privata.',
+    'ui.admin.bbs_settings.features.pgp_managed_keys_help' => 'Quando è abilitato, gli utenti possono generare coppie di chiavi PGP nel browser e salvare su questo server il blob cifrato della chiave privata.',
As per coding guidelines: “When editing French or other accented catalogs, use literal characters (e.g., 'é', 'à') only.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/it/common.php` at line 1814, Update the Italian translation
string for the key 'ui.admin.bbs_settings.features.pgp_managed_keys_help' by
replacing the unaccented "Quando e abilitato" with the correct accented phrase
"Quando è abilitato"; ensure the literal Italian accented character è is used in
the PHP array value so the admin UI displays correct Italian.
config/i18n/it/common.php-4955-5041 (1)

4955-5041: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize accenting across the new Italian PGP strings.

Several new values in this block drop accents (è, così, verrà, etc.), which makes the new PGP UI text look inconsistent with the rest of the locale.

Representative fixes
-    'ui.settings.pgp.primary_help' => 'Scegli qui una chiave, oppure usa il pulsante nella tabella sotto per impostarla come principale, cosi cambi quale chiave pubblica viene mostrata come preferita sul keyserver.',
+    'ui.settings.pgp.primary_help' => 'Scegli qui una chiave, oppure usa il pulsante nella tabella sotto per impostarla come principale, così cambi quale chiave pubblica viene mostrata come preferita sul keyserver.',

-    'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js non e ancora installato su questo BBS, quindi la generazione nel browser non e disponibile.',
+    'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js non è ancora installato su questo BBS, quindi la generazione nel browser non è disponibile.',

-    'ui.compose.pgp.encrypt_recipient_help' => 'Scegli la chiave pubblica che verra usata per cifrare questo netmail.',
+    'ui.compose.pgp.encrypt_recipient_help' => 'Scegli la chiave pubblica che verrà usata per cifrare questo netmail.',
As per coding guidelines: “When editing French or other accented catalogs, use literal characters (e.g., 'é', 'à') only.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/it/common.php` around lines 4955 - 5041, Normalize missing
Italian accents in the new PGP strings: update 'ui.settings.pgp.openpgp_missing'
to "non è ancora ... non è disponibile", 'ui.settings.pgp.primary_help' to use
"così", 'ui.compose.pgp.encrypt_recipient_help' to use "verrà",
'ui.pgp.decrypt_help' to use "è", and 'ui.settings.pgp.managed_disabled_notice'
to use "è disabilitato"; scan the other keys in this block (e.g., any string
containing " e ", "cosi", "verra", "ancora") and replace plain
"e"/"ancora"/"cosi"/"verra" with the proper accented forms (è, è ancora, così,
verrà) to make accents consistent across the locale.
config/i18n/it/errors.php-92-92 (1)

92-92: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing accent in Italian text.

The verb "è" (is) requires an accent. The string should read 'Per firmare è necessaria una chiave privata gestita.'

🔧 Proposed fix
-    'errors.pgp.private_key_required' => 'Per firmare e necessaria una chiave privata gestita.',
+    'errors.pgp.private_key_required' => 'Per firmare è necessaria una chiave privata gestita.',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/it/errors.php` at line 92, Update the translation string for the
key 'errors.pgp.private_key_required' to include the missing accent on the verb
"è" so the message reads "Per firmare è necessaria una chiave privata gestita.";
locate the entry for 'errors.pgp.private_key_required' and replace the current
value accordingly.
config/i18n/it/errors.php-735-737 (1)

735-737: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing accents in Italian text.

Multiple strings are missing required accents:

  • Line 735: "è" (is) requires an accent
  • Line 736: "è" (is) requires an accent
  • Line 737: "più" (more) requires an accent
🔧 Proposed fix
-    'errors.pgp.disabled' => 'PGP e disabilitato su questo sistema.',
-    'errors.pgp.managed_disabled' => 'La generazione di chiavi PGP gestite e disabilitata su questo sistema.',
-    'errors.pgp.passphrase_too_short' => 'Usa una passphrase PGP piu lunga.',
+    'errors.pgp.disabled' => 'PGP è disabilitato su questo sistema.',
+    'errors.pgp.managed_disabled' => 'La generazione di chiavi PGP gestite è disabilitata su questo sistema.',
+    'errors.pgp.passphrase_too_short' => 'Usa una passphrase PGP più lunga.',

As per coding guidelines: "When editing French or other accented catalogs, use literal characters (e.g., 'é', 'à') only."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/it/errors.php` around lines 735 - 737, Update the three Italian
translation strings to include the missing accents: change the value for
'errors.pgp.disabled' to use "è", update 'errors.pgp.managed_disabled' to use
"è" in "è disabilitata", and update 'errors.pgp.passphrase_too_short' to use
"più" with the accent; edit the string values for keys 'errors.pgp.disabled',
'errors.pgp.managed_disabled', and 'errors.pgp.passphrase_too_short' in
config/i18n/it/errors.php to use the literal accented characters.
public_html/js/netmail.js-1219-1223 (1)

1219-1223: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hardcoded English string violates i18n guidelines.

Line 1221 uses a raw English string "PGP encrypted" instead of an i18n key. All other PGP status strings in this function correctly use uiT().

Proposed fix
         if (!window.pgpManagedKeysEnabled) {
-            container.innerHTML = '<span class="badge bg-secondary">PGP encrypted</span>';
+            container.innerHTML = '<span class="badge bg-secondary">' + uiT('ui.pgp.encrypted', 'PGP encrypted') + '</span>';
             return;
         }

Also add the key ui.pgp.encrypted to all locale files under config/i18n/*/common.php.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public_html/js/netmail.js` around lines 1219 - 1223, Replace the hardcoded
English label in the PGP encryption branch with the i18n helper and add the new
key to locales: in the block guarded by
window.PgpMessageSupport.isEncryptedMessage(body) (the code that sets
container.innerHTML currently to '<span class="badge bg-secondary">PGP
encrypted</span>'), use the uiT('ui.pgp.encrypted') translation inside the same
badge markup instead of the raw string; then add the key "ui.pgp.encrypted" with
the appropriate translated text to every config/i18n/*/common.php locale file so
all languages show the localized label.
templates/compose.twig-2042-2045 (1)

2042-2045: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Initialization condition may be too restrictive for netmail encryption.

The condition pgpEnabled && pgpManagedKeysEnabled prevents PGP control initialization when only pgp is enabled but pgp_managed_keys is disabled. However, initPgpComposeControls() (lines 993-994) shows that netmail encryption (canEncryptInCompose) only requires the recipient's public key — it doesn't need managed keys.

Consider relaxing the condition to initialize PGP controls whenever pgpEnabled is true:

Proposed fix
-    if (pgpEnabled && pgpManagedKeysEnabled) {
+    if (pgpEnabled) {
         bindPgpRecipientControls();
         initPgpComposeControls();
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/compose.twig` around lines 2042 - 2045, The current guard only
initializes PGP UI when both pgpEnabled and pgpManagedKeysEnabled are true,
which skips initPgpComposeControls/bindPgpRecipientControls for cases where pgp
is enabled but managed keys are disabled; change the condition to require only
pgpEnabled so that initPgpComposeControls() (and bindPgpRecipientControls() if
appropriate) run whenever pgpEnabled is true, ensuring netmail encryption logic
that relies on recipient public keys (canEncryptInCompose) is active even
without managed keys.
scripts/pgp.lookup.php-166-177 (1)

166-177: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This tool does not currently reproduce the full per-user compose lookup path.

PgpLookupService only checks saved correspondent keys when a userId is supplied, but the script always calls it without user context. That means a logged-in user's compose flow can resolve a different key than this diagnostic tool. Either add a --user/--user-id option or narrow the docs so this only claims parity for local-vs-remote keyserver resolution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/pgp.lookup.php` around lines 166 - 177, The script always calls
PgpLookupService without user context so it can't reproduce per-user compose
lookups; update parseArgs to accept a --user or --user-id flag, pass that userId
into describeLookupTarget, findPublicKeyForDestination and
searchPublicKeysForDestination (or into the PgpLookupService constructor) so the
same per-user correspondent-key checks run as in compose, and ensure the
args['user'] (or args['user_id']) is threaded through when calling these
methods; if adding the flag is infeasible, restrict the script's docs/comments
to state it does not perform per-user lookup parity instead.
docs/TerminalServer.md-75-76 (1)

75-76: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document all three parser modes for completeness.

Lines 75-76 mention only decimal_relaxed and strict, but docs/TerminalServerDevGuide.md (lines 7-11) and docs/UPGRADING_1.9.8.md (lines 114-118) both list three modes including loose. For consistency and completeness, this section should briefly mention all three supported modes.

📝 Suggested fix to include all modes
-Pipe-code rendering for plain bulletins and other ANSI/pipe text shared with the web renderer is controlled by the `.env` setting `PIPE_CODE_PARSER_MODE`. The default is `decimal_relaxed`, which favors common BBS content where decimal pipe codes are immediately followed by uppercase text. `strict` remains available when avoiding false positives in prose matters more.
+Pipe-code rendering for plain bulletins and other ANSI/pipe text shared with the web renderer is controlled by the `.env` setting `PIPE_CODE_PARSER_MODE`. The default is `decimal_relaxed`, which favors common BBS content where decimal pipe codes are immediately followed by uppercase text. `strict` remains available when avoiding false positives in prose matters more. A third mode, `loose`, restores broader legacy matching for testing and comparison.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/TerminalServer.md` around lines 75 - 76, Update the
PIPE_CODE_PARSER_MODE documentation to list all three supported
modes—decimal_relaxed, strict, and loose—so it matches other docs; briefly
describe each mode (e.g., decimal_relaxed: default, favors BBS content where
decimal pipe codes are immediately followed by uppercase text; strict: avoids
false positives in prose; loose: most permissive, catches more pipe-code
variants) and ensure the setting name `PIPE_CODE_PARSER_MODE` and the three mode
names are mentioned exactly as identifiers so readers can map them to the .env
option.
src/PgpKeyService.php-228-238 (1)

228-238: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve leading zeros when normalizing PGP fingerprint search input (strip only an optional 0x/0X prefix).

strtoupper(ltrim($search, '0x')) removes all leading 0 characters (and only lowercase x), so fingerprints that legitimately start with 0 won’t match, and an uppercase 0X prefix won’t be removed either. Update normalization in src/PgpKeyService.php (around lines 228-239 and 259-271).

Suggested fix
-                $normalizedFingerprint = strtoupper(ltrim($search, '0x'));
+                $normalizedFingerprint = strtoupper((string) preg_replace('/^0x/i', '', $search));-        $normalizedFingerprint = strtoupper(ltrim($search, '0x'));
+        $normalizedFingerprint = strtoupper((string) preg_replace('/^0x/i', '', $search));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PgpKeyService.php` around lines 228 - 238, The current normalization
using strtoupper(ltrim($search, '0x')) incorrectly strips all leading zeros and
only removes lowercase 'x'; change it to remove only a single optional "0x" or
"0X" prefix and then uppercase the remainder so leading zeros are
preserved—e.g., detect and strip a single leading 0x/0X (or use a regex like
^0[xX]) on the $search value before calling strtoupper, assign that to
$normalizedFingerprint where used in PgpKeyService (the block building $sql and
$params and the similar normalization later around the other referenced block),
and keep the rest of the LIKE params intact.
src/PgpContactKeyService.php-139-153 (1)

139-153: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve leading zeroes when normalizing fingerprint searches.

ltrim($search, '0x') removes all leading 0 and x characters, so a valid fingerprint like 0x00AB... becomes AB..., breaking the k.fingerprint = ? exact-match path. Store/search fingerprints are derived/stored as 40-char SHA1 hex (leading zeros possible), so stripping them is incorrect.

Suggested fix
-        $normalizedFingerprint = strtoupper(ltrim($search, '0x'));
+        $normalizedFingerprint = strtoupper((string) preg_replace('/^0x/i', '', $search));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PgpContactKeyService.php` around lines 139 - 153, The code strips all
leading '0' and 'x' chars via ltrim($search, '0x'), which removes significant
leading zeros from fingerprints; instead detect and strip only a single optional
"0x" or "0X" prefix (e.g. if (stripos($search, '0x') === 0)
$normalizedFingerprint = substr($search, 2); else $normalizedFingerprint =
$search), then uppercase that value for the k.fingerprint exact match. Update
the use of $normalizedFingerprint and the params pushed into array_push($params,
...) so the exact-match param still receives the correctly normalized 40-char
hex string, and leave the wildcard $like/$exact behavior for the other LIKE
clauses unchanged (refer to $normalizedFingerprint, ltrim($search, '0x'), the
WHERE block and array_push call to locate the change).
🧹 Nitpick comments (11)
public_html/js/pgp-message.js (1)

450-467: 💤 Low value

Unused signerSearch parameter.

The signerSearch parameter is declared but never used in the function body. The function always uses getCurrentUserPrivateKeyInfo() which selects the preferred key regardless of any search criteria.

If this parameter is intended for future key selection by search criteria, consider adding a TODO comment. Otherwise, remove it to avoid confusion.

Option 1: Remove unused parameter
-    async function signCleartextMessage(text, passphrase, signerSearch) {
+    async function signCleartextMessage(text, passphrase) {

Update the export and any callers accordingly.

Option 2: Document for future use
+    // TODO: signerSearch is reserved for future key selection by fingerprint/user ID
     async function signCleartextMessage(text, passphrase, signerSearch) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public_html/js/pgp-message.js` around lines 450 - 467, The signerSearch
parameter in signCleartextMessage is unused — remove signerSearch from the
function signature and update any exports/callers to stop passing it; keep the
body using getCurrentUserPrivateKeyInfo() and
getCurrentUserDecryptedPrivateKey(passphrase) as-is. If you intend to support
alternate key selection later, instead leave signerSearch out of runtime flow
but add a single-line TODO inside signCleartextMessage referencing signerSearch
and noting it will be used for alternative key lookup (e.g., via
getCurrentUserPrivateKeyInfo) when implemented.
src/Realtime/EventListenerInterface.php (3)

15-20: ⚡ Quick win

Consider documenting timeout behavior.

The wait() method doesn't specify what is returned when the timeout expires with no notifications. Consider adding a note that an empty array is returned on timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Realtime/EventListenerInterface.php` around lines 15 - 20, The docblock
for the wait(int $timeoutMs): array method is missing behavior on timeouts;
update the PHPDoc for EventListenerInterface::wait to state that it returns an
empty array when the timeout expires with no notifications (i.e., no payloads),
so callers know to expect list<string> and can distinguish no-results vs
blocking; reference the wait method name in the comment and include the
timeoutMs parameter description.

10-13: ⚡ Quick win

Consider documenting the return value and edge cases.

The listen() method returns bool but doesn't explain what true or false signifies. Consider documenting:

  • What the return value indicates (success/failure?)
  • Behavior when already subscribed to a channel
  • Any exceptions that might be thrown
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Realtime/EventListenerInterface.php` around lines 10 - 13, Update the
docblock for EventListenerInterface::listen to explicitly describe what the
boolean return value means (e.g., true on successful subscription, false on
failure), document behavior when attempting to subscribe to an
already-subscribed channel (idempotent and returns true / or returns false —
choose and state it), and list any exceptions the implementation may throw
(e.g., TransportException, InvalidArgumentException) or state that none are
thrown; ensure the comment mentions side effects (registering handlers) and any
preconditions for the channel string so implementers of listen() know expected
behavior.

27-30: ⚡ Quick win

Consider clarifying state management and return value.

The reconnect() documentation mentions "the active channel" which implies internal state tracking, but the interface doesn't expose methods to query this state. Consider:

  • Documenting that implementations must track the last subscribed channel
  • Explaining what the return value indicates
  • Noting behavior if no channel was previously subscribed
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Realtime/EventListenerInterface.php` around lines 27 - 30, Update the
PHPDoc for the EventListenerInterface::reconnect() method to explicitly state
how implementations must manage subscription state and what the boolean return
value means: document that implementations are expected to persist the "last
subscribed channel" (or return false/throw if none exists), define return true
on successful reconnect-and-resubscribe and false on failure (including the case
where no prior channel was present), and describe any expected side effects
(e.g., re-subscribe attempt, error handling). Also mention the behavior when
there is no previously subscribed channel (e.g., no-op and return false or
throw) so implementers know the contract.
src/Realtime/BinkStream.php (1)

10-20: ⚡ Quick win

Expand phpDoc to include parameter and return type documentation.

The method signature now has six parameters and returns a nullable int. Complete phpDoc improves IDE support and maintainability.

📝 Proposed phpDoc expansion
     /**
      * Insert a realtime event row and wake listeners through the configured publisher.
+     *
+     * `@param` PDO $db Database connection
+     * `@param` string $eventType Event type identifier
+     * `@param` array $payload Event payload data
+     * `@param` int|null $userId Target user ID, or null for broadcast
+     * `@param` bool $adminOnly Whether event is admin-only
+     * `@param` EventPublisherInterface|null $publisher Event publisher, defaults to PostgresEventPublisher
+     * `@return` int|null The inserted event ID, or null on failure
      */
     public static function emit(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Realtime/BinkStream.php` around lines 10 - 20, Update the phpDoc for the
BinkStream::emit method to document all parameters and the return value: add
`@param` tags for PDO $db, string $eventType, array $payload, ?int $userId, bool
$adminOnly, and ?EventPublisherInterface $publisher, and add an `@return` int|null
describing the nullable integer ID returned (or null on failure). Also include a
brief description of what the method does and note any side effects (e.g.,
inserts a realtime event row and wakes listeners via the publisher) to improve
IDE hints and maintainability.
templates/shells/web/base.twig (1)

278-280: ⚡ Quick win

Keyserver label i18n OK; re-check bbs-menu parity

  • ui.settings.pgp.heading exists in all locale catalogs present (config/i18n/{de,en,es,fr,it}/common.php).
  • No Keyserver/PGP link exists anywhere under templates/shells/bbs-menu/*; if the bbs-menu main menu should mirror the web menu, add the Keyserver item there using the same translation key.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/shells/web/base.twig` around lines 278 - 280, The Keyserver/PGP
menu item is present in web/base.twig but missing from the bbs-menu templates;
update the templates under the bbs-menu (the menu partial(s) that build the main
BBS menu) to add the same conditional item guarded by bbs_feature_enabled('pgp')
and using the same translation key ui.settings.pgp.heading (so it mirrors the
web menu); ensure the inserted item matches the markup/placement of the other
menu entries and uses the same href (/keyserver) and icon class (fas fa-key).
telnet/src/BulletinsHandler.php (1)

254-267: 💤 Low value

Consider caching the parser mode to avoid repeated environment reads.

getPipeCodeParserMode() is called at least twice per bulletin (lines 208, 223, and transitively at 262). Since the mode won't change during a single request, consider reading it once in the constructor or caching it in a private field.

♻️ Optional refactor
 class BulletinsHandler
 {
     private BbsSession $server;
     private string $apiBase;
+    private string $pipeCodeParserMode;

     public function __construct(BbsSession $server, string $apiBase)
     {
         $this->server = $server;
         $this->apiBase = $apiBase;
+        $mode = strtolower(trim((string)Config::env('PIPE_CODE_PARSER_MODE', 'decimal_relaxed')));
+        $this->pipeCodeParserMode = in_array($mode, ['strict', 'decimal_relaxed', 'loose'], true) ? $mode : 'decimal_relaxed';
     }

Then replace $this->getPipeCodeParserMode() calls with $this->pipeCodeParserMode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@telnet/src/BulletinsHandler.php` around lines 254 - 267, Cache the parsed
pipe-code parser mode in a private property to avoid repeated environment reads:
add a private property (e.g., $pipeCodeParserMode), initialize it once
(preferably in the constructor) by calling the existing getPipeCodeParserMode
logic or by moving the mode-detection code there, and then replace all calls to
$this->getPipeCodeParserMode() (used in getPipeCodeColorPattern and elsewhere)
with the cached $this->pipeCodeParserMode so subsequent accesses use the stored
value for the lifetime of the handler instance.
scripts/database_maintenance.php (1)

267-295: 💤 Low value

Consider clarifying the dry-run message.

Line 287 says "Would evaluate ... for stale-process cleanup" but the actual operation marks rows as failed. Consider rewording to "Would mark X stale active BinkP sessions as failed" for clarity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/database_maintenance.php` around lines 267 - 295, Update the dry-run
echo to accurately reflect the real action: instead of "Would evaluate ... for
stale-process cleanup" print a message saying "Would mark X stale active BinkP
sessions as failed" (use the same $result['count'] value). Locate the dry-run
branch around the binkp_session_log check where $stmt/$result are used and
ensure the wording mirrors the non-dry-run branch that calls
SessionLogger::cleanupStaleActiveSessions and updates $totalCleaned.
templates/settings.twig (3)

1371-1373: 💤 Low value

Consider logging error details for debugging.

The catch block discards the actual error, making it difficult to diagnose key generation failures. Consider logging the error to console for debugging purposes.

Suggested improvement
     } catch (error) {
+        console.error('PGP key generation failed:', error);
         setPgpStatus('danger', uiT('errors.pgp.generation_failed', 'Failed to generate managed PGP key.'));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/settings.twig` around lines 1371 - 1373, The catch block that calls
setPgpStatus('danger', uiT('errors.pgp.generation_failed', 'Failed to generate
managed PGP key.')) swallows the error; update the catch to also log the caught
error (e.g., console.error or the app logger) so the actual error object/message
is recorded for debugging while keeping the existing user-facing setPgpStatus
call; reference the same catch handling around the PGP generation code and add a
console.error(error) (or logger.error) line there.

697-774: 💤 Low value

PGP modal rendered outside the conditional block.

The pgpKeyDetailsModal is rendered unconditionally (after line 695 where {% endif %} closes the pgp_enabled check), so it will be included in the page even when PGP is disabled. Consider wrapping it in the same conditional to avoid rendering unused markup.

Suggested fix
             </div>
             {% endif %}
 
+            {% if pgp_enabled %}
             <div class="modal fade" id="pgpKeyDetailsModal" tabindex="-1" aria-hidden="true">
                 ...
             </div>
+            {% endif %}
 
             {# ------------------------------------------------------------------ #}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/settings.twig` around lines 697 - 774, The PGP details modal
(element id "pgpKeyDetailsModal" and buttons calling
copyPgpPublicKeyFromDetails()/downloadPgpPublicKeyFromDetails()) is rendered
outside the pgp_enabled conditional; wrap the entire modal markup inside the
same {% if pgp_enabled %} ... {% endif %} (or add a new {% if pgp_enabled %}
before and {% endif %} after the modal) so the modal is only included when
pgp_enabled is true.

2222-2224: 💤 Low value

escapeJs may be incomplete for arbitrary strings.

The function only escapes backslashes and single quotes. For hex fingerprints this is sufficient, but if reused for other user input, it could miss newlines (\n), carriage returns (\r), or other control characters that break JS string literals. Since it's currently only used with fingerprints, this is acceptable but worth noting if the function is reused elsewhere.

More robust escapeJs (optional)
 function escapeJs(text) {
-    return String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+    return String(text)
+        .replace(/\\/g, '\\\\')
+        .replace(/'/g, "\\'")
+        .replace(/\n/g, '\\n')
+        .replace(/\r/g, '\\r');
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/settings.twig` around lines 2222 - 2224, The escapeJs function only
escapes backslashes and single quotes which can allow control characters (e.g.,
newline \n, carriage return \r, tab \t) or other quotes to break JS string
literals; update escapeJs to also replace control characters and other
problematic characters (at minimum: \n, \r, \t, double quotes ") and any other
common JS-breaking control chars so it produces a safe single-quoted JS string;
modify the existing function escapeJs to perform these additional replacements
while preserving current backslash and single-quote handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: eaa291d8-328f-4c5f-964b-609b1a256974

📥 Commits

Reviewing files that changed from the base of the PR and between f719fae and e6c0d10.

⛔ Files ignored due to path filters (1)
  • public_html/vendor/openpgp-6.2.2/openpgp.min.js is excluded by !**/*.min.js
📒 Files selected for processing (80)
  • CLAUDE.md
  • README.md
  • config/bbs.json.example
  • config/i18n/de/common.php
  • config/i18n/de/errors.php
  • config/i18n/en/common.php
  • config/i18n/en/errors.php
  • config/i18n/es/common.php
  • config/i18n/es/errors.php
  • config/i18n/fr/common.php
  • config/i18n/fr/errors.php
  • config/i18n/it/common.php
  • config/i18n/it/errors.php
  • database/migrations/v20260602033958_add_user_pgp_keys.sql
  • database/migrations/v20260602191017_add_pgp_contact_keys.sql
  • database/migrations/v20260603181626_increase_user_settings_theme_length.sql
  • docs/ANSI_Support.md
  • docs/API.md
  • docs/CLI.md
  • docs/CONFIGURATION.md
  • docs/ConfigurationSystem.md
  • docs/DEVELOPER_GUIDE.md
  • docs/INSTALL.md
  • docs/PGP.md
  • docs/Pipe_Code_Support.md
  • docs/PostgreSQLDependencies.md
  • docs/TerminalServer.md
  • docs/TerminalServerDevGuide.md
  • docs/UPGRADING_1.9.8.md
  • docs/index.md
  • public_html/js/ansisys.js
  • public_html/js/echomail.js
  • public_html/js/netmail.js
  • public_html/js/pgp-message.js
  • public_html/sw.js
  • public_html/webdoors/mrc/index.php
  • routes/admin-routes.php
  • routes/api-routes.php
  • routes/web-routes.php
  • scripts/ai_bot_daemon.php
  • scripts/database_maintenance.php
  • scripts/install.php
  • scripts/pgp.lookup.php
  • src/AddressBookController.php
  • src/Admin/AdminDaemonServer.php
  • src/Auth.php
  • src/BbsConfig.php
  • src/Binkp/Protocol/BinkpClient.php
  • src/Binkp/Protocol/BinkpServer.php
  • src/Binkp/SessionLogger.php
  • src/Config.php
  • src/Crashmail/CrashmailService.php
  • src/Database.php
  • src/DatabasePlatform/DatabasePlatformFactory.php
  • src/DatabasePlatform/DatabasePlatformInterface.php
  • src/DatabasePlatform/PostgresPlatform.php
  • src/MessageHandler.php
  • src/Pgp/ArmoredPublicKeyParser.php
  • src/PgpContactKeyService.php
  • src/PgpKeyService.php
  • src/PgpLookupService.php
  • src/Realtime/BinkStream.php
  • src/Realtime/EventListenerInterface.php
  • src/Realtime/EventPublisherInterface.php
  • src/Realtime/PostgresEventListener.php
  • src/Realtime/PostgresEventPublisher.php
  • src/Realtime/PostgresSseEventMaintenance.php
  • src/Realtime/SseEventMaintenanceInterface.php
  • src/Template.php
  • telnet/src/BulletinsHandler.php
  • templates/admin/bbs_settings.twig
  • templates/base.twig
  • templates/compose.twig
  • templates/dashboard.twig
  • templates/echomail.twig
  • templates/keyserver.twig
  • templates/netmail.twig
  • templates/settings.twig
  • templates/shells/bbs-menu/base.twig
  • templates/shells/web/base.twig

Comment thread config/i18n/de/common.php
Comment on lines +4970 to +5056
'ui.settings.tab.pgp' => 'PGP',
'ui.settings.pgp.heading' => 'PGP-Schluessel',
'ui.settings.pgp.help' => 'Oeffentliche Schluessel hochladen, BBS-verwaltete private Schluessel erzeugen und den bevorzugten oeffentlichen Schluessel fuer den Keyserver auswaehlen.',
'ui.settings.pgp.upload_heading' => 'Oeffentlichen Schluessel hochladen',
'ui.settings.pgp.key_label' => 'Schluesselbezeichnung',
'ui.settings.pgp.key_label_placeholder' => 'Laptop-Schluessel, Arbeitsschluessel, Archivschluessel',
'ui.settings.pgp.public_key' => 'ASCII-armored oeffentlicher Schluessel',
'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----',
'ui.settings.pgp.upload_button' => 'Oeffentlichen Schluessel hochladen',
'ui.settings.pgp.generate_heading' => 'BBS-verwalteten Schluessel erzeugen',
'ui.settings.pgp.managed_label_placeholder' => 'BinktermPHP verwalteter Schluessel',
'ui.settings.pgp.passphrase' => 'PGP-Passphrase',
'ui.settings.pgp.passphrase_placeholder' => 'PGP-Passphrase eingeben',
'ui.settings.pgp.passphrase_help' => 'Verwenden Sie eine Passphrase, die sich von Ihrem Login-Passwort unterscheidet.',
'ui.settings.pgp.passphrase_confirm' => 'Passphrase bestaetigen',
'ui.settings.pgp.passphrase_confirm_placeholder' => 'PGP-Passphrase erneut eingeben',
'ui.settings.pgp.generate_button' => 'Verwalteten Schluessel erzeugen',
'ui.settings.pgp.generate_help' => 'Dieses erzeugt das Schluesselpaar im Browser und speichert nur den verschluesselten privaten Schluessel auf dem Server.',
'ui.settings.pgp.managed_disabled_notice' => 'Diese BBS erlaubt das Hochladen oeffentlicher PGP-Schluessel, aber das Hosten BBS-verwalteter privater Schluessel ist vom Sysop deaktiviert.',
'ui.settings.pgp.saved_keys_heading' => 'Gespeicherte Schluessel',
'ui.settings.pgp.keyserver_button' => 'Keyserver durchsuchen',
'ui.settings.pgp.no_keys' => 'Noch keine PGP-Schluessel gespeichert.',
'ui.settings.pgp.col_primary' => 'Status',
'ui.settings.pgp.col_label' => 'Bezeichnung',
'ui.settings.pgp.col_fingerprint' => 'Fingerprint',
'ui.settings.pgp.col_source' => 'Quelle',
'ui.settings.pgp.col_created' => 'Erstellt',
'ui.settings.pgp.col_actions' => 'Aktionen',
'ui.settings.pgp.source_managed' => 'BBS verwaltet',
'ui.settings.pgp.source_uploaded' => 'Hochgeladen',
'ui.settings.pgp.private_key_available' => 'Verschluesselter privater Schluessel gespeichert',
'ui.settings.pgp.primary_selector' => 'Bevorzugter oeffentlicher Schluessel',
'ui.settings.pgp.primary_help' => 'Waehle hier einen Schluessel aus oder nutze die Schaltflaeche "Als primaer festlegen" in der Tabelle unten, um zu aendern, welcher oeffentliche Schluessel auf dem Keyserver bevorzugt angezeigt wird.',
'ui.settings.pgp.primary_selected' => 'Primaer',
'ui.settings.pgp.secondary_key' => 'Sekundaer',
'ui.settings.pgp.make_primary_button' => 'Als primaer festlegen',
'ui.settings.pgp.current_primary_button' => 'Aktuell primaer',
'ui.settings.pgp.uploading' => 'Oeffentlichen Schluessel wird hochgeladen...',
'ui.settings.pgp.upload_success' => 'PGP-Schluessel gespeichert.',
'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js ist auf diesem BBS noch nicht installiert, daher ist die Browser-Schluesselerzeugung nicht verfuegbar.',
'ui.settings.pgp.generating' => 'PGP-Schluesselpaar wird erzeugt...',
'ui.settings.pgp.generate_success' => 'Verwalteter PGP-Schluessel wurde erzeugt und gespeichert.',
'ui.settings.pgp.primary_updated' => 'Primaerer PGP-Schluessel aktualisiert.',
'ui.settings.pgp.delete_confirm' => 'Diesen PGP-Schluessel loeschen?',
'ui.settings.pgp.delete_success' => 'PGP-Schluessel geloescht.',
'ui.settings.pgp.copy_success' => 'Fingerprint kopiert.',
'ui.settings.pgp.copy_public_key_button' => 'Oeffentlichen Schluessel kopieren',
'ui.settings.pgp.download_public_key_button' => 'Oeffentlichen Schluessel herunterladen',
'ui.settings.pgp.copy_public_key_success' => 'Oeffentlicher Schluessel kopiert.',
'ui.settings.pgp.key_details_title' => 'PGP-Schluesseldetails',
'ui.settings.pgp.algorithm' => 'Algorithmus',
'ui.compose.pgp.title' => 'PGP',
'ui.compose.pgp.encrypt_netmail' => 'Dieses Netmail verschlüsseln',
'ui.compose.pgp.encrypt_netmail_help' => 'Verschlüsselt die Nachricht für den Empfänger mit dessen veröffentlichtem öffentlichen Schlüssel.',
'ui.compose.pgp.sign_echomail' => 'Dieses Echomail signieren',
'ui.compose.pgp.sign_echomail_help' => 'Signiert die Nachricht mit Deinem gespeicherten verwalteten privaten Schlüssel.',
'ui.compose.pgp.encrypt_only_notice' => 'Netmail-Verschluesselung braucht nur den oeffentlichen Schluessel des Empfaengers. Signieren und Entschluesseln erfordern Deinen verwalteten privaten Schluessel.',
'ui.compose.pgp.encrypt_recipient_label' => 'Öffentlicher Schlüssel des Empfängers',
'ui.compose.pgp.encrypt_recipient_help' => 'Wähle den öffentlichen Schlüssel, mit dem dieses Netmail verschlüsselt wird.',
'ui.compose.pgp.encrypt_recipient_placeholder' => 'Öffentlichen Schlüssel zum Verschlüsseln auswählen',
'ui.compose.pgp.encrypt_no_key' => 'Kein passender öffentlicher Schlüssel gefunden.',
'ui.compose.pgp.encrypt_no_key_help' => 'Gib einen Empfänger mit veröffentlichtem öffentlichen Schlüssel an.',
'ui.compose.pgp.encrypting_for' => 'Verschlüsselung für {recipient}',
'ui.pgp.notice' => 'PGP-Aktionen verwenden Deinen gespeicherten verwalteten privaten Schlüssel. Wenn Du nur einen öffentlichen Schlüssel hochgeladen hast, sind Signieren und Entschlüsseln nicht verfügbar.',
'ui.pgp.decrypt_button' => 'Entschlüsseln',
'ui.pgp.decrypt_help' => 'Diese Nachricht ist mit PGP verschlüsselt.',
'ui.pgp.passphrase_prompt' => 'Gib Deine PGP-Passphrase ein, um diese Nachricht zu entschlüsseln.',
'ui.pgp.decrypted' => 'PGP entschlüsselt',
'ui.pgp.verifying' => 'PGP-Signatur wird überprüft...',
'ui.pgp.verified' => 'PGP-Signatur bestätigt',
'ui.pgp.no_public_key' => 'Öffentlicher PGP-Schlüssel nicht gefunden',
'ui.pgp.invalid' => 'Ungültige PGP-Signatur',
'ui.keyserver.title' => 'PGP-Keyserver',
'ui.keyserver.manage_keys' => 'Meine Schluessel verwalten',
'ui.keyserver.search_label' => 'Suche',
'ui.keyserver.search_placeholder' => 'Benutzername, Realname, E-Mail oder Fingerprint',
'ui.keyserver.search_help' => 'Suchen Sie nach Kontoname, E-Mail-Adresse, vollstaendigem Fingerprint oder einer entfernten qualifizierten Suche wie awehttam@227:1/200 oder foobar@claudes.lovelybits.org.',
'ui.keyserver.search_button' => 'Suchen',
'ui.keyserver.results_heading' => 'Oeffentliche Schluessel',
'ui.keyserver.col_user' => 'Benutzer',
'ui.keyserver.col_fingerprint' => 'Fingerprint',
'ui.keyserver.col_type' => 'Typ',
'ui.keyserver.col_created' => 'Erstellt',
'ui.keyserver.col_actions' => 'Aktionen',
'ui.keyserver.primary_badge' => 'Primaer',
'ui.keyserver.download_button' => 'Herunterladen',
'ui.keyserver.no_results' => 'Keine oeffentlichen Schluessel entsprechen Ihrer Suche.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# run locally before merge
php scripts/check_i18n_syntax.php --locale=de
git diff -- public_html/sw.js

Repository: awehttam/binkterm-php

Length of output: 214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== sw.js CACHE_NAME diff ==="
git diff -- public_html/sw.js | sed -n '1,200p'

echo
echo "=== current sw.js CACHE_NAME ==="
rg -n "CACHE_NAME" public_html/sw.js || true
sed -n '1,220p' public_html/sw.js | rg -n "CACHE_NAME|binkcache-" || true

Repository: awehttam/binkterm-php

Length of output: 1215


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "i18n|config/i18n|translator|locale|common\.php|errors\.php|terminalserver\.php|/de/" public_html/sw.js || true

Repository: awehttam/binkterm-php

Length of output: 378


🏁 Script executed:

#!/bin/bash
set -euo pipefail
nl -ba public_html/sw.js | sed -n '1,220p'
nl -ba public_html/sw.js | sed -n '220,320p'

Repository: awehttam/binkterm-php

Length of output: 108


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from itertools import islice
path="public_html/sw.js"
with open(path,'r',encoding='utf-8') as f:
    lines=list(f)
for start,end in [(120,220),(180,260)]:
    print(f"\n=== {path} lines {start}-{end} ===")
    for i in range(start-1,end):
        if i<0 or i>=len(lines): continue
        print(f"{i+1}: {lines[i].rstrip()}")
PY

Repository: awehttam/binkterm-php

Length of output: 6064


Update SERVICE WORKER cache version for i18n changes (de/common.php)

  • php scripts/check_i18n_syntax.php --locale=de passes (includes i18n/de/common.php).
  • public_html/sw.js CACHE_NAME was not incremented in this change; since the service worker caches /api/i18n/catalog cache-first, bump CACHE_NAME so clients fetch the updated German catalog.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/de/common.php` around lines 4970 - 5056, The service worker cache
version wasn't bumped after updating i18n/de/common.php, so clients may still
serve the old /api/i18n/catalog; open public_html/sw.js, locate the CACHE_NAME
constant (and any CACHE_VERSION or cache name string) and increment its version
value (e.g. append or increment the numeric suffix) so the service worker treats
caches as changed and fetches the updated German catalog; then rebuild/deploy so
clients get the new sw.js.

Comment thread config/i18n/fr/common.php
Comment on lines +4897 to +4983
'ui.settings.tab.pgp' => 'PGP',
'ui.settings.pgp.heading' => 'Cles PGP',
'ui.settings.pgp.help' => 'Televersez des cles publiques, generez des cles privees gerees par le BBS et choisissez la cle publique principale a publier sur le serveur de cles.',
'ui.settings.pgp.upload_heading' => 'Televerser une cle publique',
'ui.settings.pgp.key_label' => 'Etiquette de la cle',
'ui.settings.pgp.key_label_placeholder' => 'Cle portable, travail, archive',
'ui.settings.pgp.public_key' => 'Cle publique ASCII-armored',
'ui.settings.pgp.public_key_placeholder' => '-----BEGIN PGP PUBLIC KEY BLOCK-----',
'ui.settings.pgp.upload_button' => 'Televerser la cle publique',
'ui.settings.pgp.generate_heading' => 'Generer une cle geree par le BBS',
'ui.settings.pgp.managed_label_placeholder' => 'Cle geree par BinktermPHP',
'ui.settings.pgp.passphrase' => 'Phrase de passe PGP',
'ui.settings.pgp.passphrase_placeholder' => 'Saisissez une phrase de passe PGP',
'ui.settings.pgp.passphrase_help' => 'Utilisez une phrase de passe differente de votre mot de passe de connexion.',
'ui.settings.pgp.passphrase_confirm' => 'Confirmer la phrase de passe',
'ui.settings.pgp.passphrase_confirm_placeholder' => 'Saisissez de nouveau votre phrase de passe PGP',
'ui.settings.pgp.generate_button' => 'Generer la cle geree',
'ui.settings.pgp.generate_help' => 'Cela genere la paire de cles dans le navigateur et seul le blob chiffre de la cle privee est stocke sur le serveur.',
'ui.settings.pgp.managed_disabled_notice' => 'Ce BBS autorise le televersement de cles publiques PGP, mais l\'hebergement de cles privees gerees par le BBS est desactive par le sysop.',
'ui.settings.pgp.saved_keys_heading' => 'Cles enregistrees',
'ui.settings.pgp.keyserver_button' => 'Parcourir le serveur de cles',
'ui.settings.pgp.no_keys' => 'Aucune cle PGP enregistree pour le moment.',
'ui.settings.pgp.col_primary' => 'Statut',
'ui.settings.pgp.col_label' => 'Etiquette',
'ui.settings.pgp.col_fingerprint' => 'Empreinte',
'ui.settings.pgp.col_source' => 'Source',
'ui.settings.pgp.col_created' => 'Creation',
'ui.settings.pgp.col_actions' => 'Actions',
'ui.settings.pgp.source_managed' => 'Geree par le BBS',
'ui.settings.pgp.source_uploaded' => 'Televersee',
'ui.settings.pgp.private_key_available' => 'Cle privee chiffree stockee',
'ui.settings.pgp.primary_selector' => 'Cle publique preferee',
'ui.settings.pgp.primary_help' => 'Choisissez ici une cle, ou utilisez le bouton du tableau ci-dessous pour la definir comme principale, afin de changer la cle publique affichee comme preferee sur le serveur de cles.',
'ui.settings.pgp.primary_selected' => 'Principale',
'ui.settings.pgp.secondary_key' => 'Secondaire',
'ui.settings.pgp.make_primary_button' => 'Definir principale',
'ui.settings.pgp.current_primary_button' => 'Principale actuelle',
'ui.settings.pgp.uploading' => 'Televersement de la cle publique...',
'ui.settings.pgp.upload_success' => 'Cle PGP enregistree.',
'ui.settings.pgp.openpgp_missing' => 'OpenPGP.js n\'est pas encore installe sur ce BBS, donc la generation de cles dans le navigateur n\'est pas disponible.',
'ui.settings.pgp.generating' => 'Generation de la paire de cles PGP...',
'ui.settings.pgp.generate_success' => 'Cle PGP geree generee et enregistree.',
'ui.settings.pgp.primary_updated' => 'Cle PGP principale mise a jour.',
'ui.settings.pgp.delete_confirm' => 'Supprimer cette cle PGP ?',
'ui.settings.pgp.delete_success' => 'Cle PGP supprimee.',
'ui.settings.pgp.copy_success' => 'Empreinte copiee.',
'ui.settings.pgp.copy_public_key_button' => 'Copier la cle publique',
'ui.settings.pgp.download_public_key_button' => 'Telecharger la cle publique',
'ui.settings.pgp.copy_public_key_success' => 'Cle publique copiee.',
'ui.settings.pgp.key_details_title' => 'Détails de la clé PGP',
'ui.settings.pgp.algorithm' => 'Algorithme',
'ui.compose.pgp.title' => 'PGP',
'ui.compose.pgp.encrypt_netmail' => 'Chiffrer ce netmail',
'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a l’aide de sa cle publique publiee.',
'ui.compose.pgp.sign_echomail' => 'Signer cet echomail',
'ui.compose.pgp.sign_echomail_help' => 'Signe le message avec votre cle privee geree stockee.',
'ui.compose.pgp.encrypt_only_notice' => 'Le chiffrement netmail a seulement besoin de la cle publique du destinataire. La signature et le dechiffrement exigent votre cle privee geree.',
'ui.compose.pgp.encrypt_recipient_label' => 'Cle publique du destinataire',
'ui.compose.pgp.encrypt_recipient_help' => 'Choisissez la cle publique qui sera utilisee pour chiffrer ce netmail.',
'ui.compose.pgp.encrypt_recipient_placeholder' => 'Selectionnez la cle publique pour le chiffrement',
'ui.compose.pgp.encrypt_no_key' => 'Aucune cle publique correspondante trouvee.',
'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant d’une cle publique publiee.',
'ui.compose.pgp.encrypting_for' => 'Chiffrement pour {recipient}',
'ui.pgp.notice' => 'Les actions PGP utilisent votre cle privee geree stockee. Si vous avez seulement televerse une cle publique, la signature et le dechiffrement ne sont pas disponibles.',
'ui.pgp.decrypt_button' => 'Dechiffrer',
'ui.pgp.decrypt_help' => 'Ce message est chiffre avec PGP.',
'ui.pgp.passphrase_prompt' => 'Saisissez votre phrase de passe PGP pour dechiffrer ce message.',
'ui.pgp.decrypted' => 'PGP dechiffre',
'ui.pgp.verifying' => 'Verification de la signature PGP...',
'ui.pgp.verified' => 'Signature PGP verifiee',
'ui.pgp.no_public_key' => 'Cle publique PGP introuvable',
'ui.pgp.invalid' => 'Signature PGP invalide',
'ui.keyserver.title' => 'Serveur de cles PGP',
'ui.keyserver.manage_keys' => 'Gerer mes cles',
'ui.keyserver.search_label' => 'Recherche',
'ui.keyserver.search_placeholder' => 'Nom d\'utilisateur, nom reel, courriel ou empreinte',
'ui.keyserver.search_help' => 'Recherchez par nom de compte, adresse courriel, empreinte complete ou recherche distante qualifiee comme awehttam@227:1/200 ou foobar@claudes.lovelybits.org.',
'ui.keyserver.search_button' => 'Rechercher',
'ui.keyserver.results_heading' => 'Cles publiques',
'ui.keyserver.col_user' => 'Utilisateur',
'ui.keyserver.col_fingerprint' => 'Empreinte',
'ui.keyserver.col_type' => 'Type',
'ui.keyserver.col_created' => 'Creation',
'ui.keyserver.col_actions' => 'Actions',
'ui.keyserver.primary_badge' => 'Principale',
'ui.keyserver.download_button' => 'Telecharger',
'ui.keyserver.no_results' => 'Aucune cle publique ne correspond a votre recherche.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Required syntax/encoding checks for edited FR catalog
php scripts/check_i18n_syntax.php --locale=fr

# 2) Repo-specific i18n checks from project learnings
php scripts/check_i18n_hardcoded_strings.php
php scripts/check_i18n_error_keys.php

# 3) Verify new FR keys exist in other locales' common catalogs
python - <<'PY'
import re, pathlib, sys

locales = ["en","de","es","it","fr"]
files = {loc: pathlib.Path(f"config/i18n/{loc}/common.php") for loc in locales}
for loc,p in files.items():
    if not p.exists():
        print(f"[missing_file] {p}")
        sys.exit(1)

pat = re.compile(r"^\s*'([^']+)'\s*=>", re.M)

keys = {loc: set(pat.findall(files[loc].read_text(encoding="utf-8", errors="strict"))) for loc in locales}
fr_new = {k for k in keys["fr"] if (
    k.startswith("ui.settings.pgp.")
    or k.startswith("ui.compose.pgp.")
    or k.startswith("ui.keyserver.")
    or k.startswith("ui.pgp.")
    or k in {
        "ui.settings.tab.pgp",
        "ui.address_book.pgp_public_key",
        "ui.address_book.pgp_public_key_placeholder",
        "ui.address_book.pgp_public_key_help",
        "ui.address_book.pgp_key_linked",
        "ui.admin.bbs_settings.features.enable_pgp",
        "ui.admin.bbs_settings.features.pgp_help",
        "ui.admin.bbs_settings.features.enable_pgp_managed_keys",
        "ui.admin.bbs_settings.features.pgp_managed_keys_help",
    }
)}

missing = {}
for loc in ["en","de","es","it"]:
    diff = sorted(fr_new - keys[loc])
    if diff:
        missing[loc] = diff

if not fr_new:
    print("[warn] No targeted FR PGP keys found by detector.")
if missing:
    for loc, diff in missing.items():
        print(f"\n[missing_in_{loc}]")
        for k in diff:
            print(k)
    sys.exit(2)

print("[ok] Targeted FR PGP keys exist in en/de/es/it common catalogs.")
PY

# 4) Verify service worker cache name was updated in this PR branch
rg -n "CACHE_NAME" public_html/sw.js

Repository: awehttam/binkterm-php

Length of output: 1046


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show whether CACHE_NAME changed in this PR for sw.js
git diff --unified=0 -- public_html/sw.js | rg -n "CACHE_NAME|binkcache" || true

# Also show the exact current CACHE_NAME line for context
rg -n "const CACHE_NAME" public_html/sw.js

Repository: awehttam/binkterm-php

Length of output: 104


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact change set for sw.js (focus on CACHE_NAME)
git diff --unified=5 -- public_html/sw.js | rg -n "CACHE_NAME|binkcache|Version|purge|UPDATE_AVAILABLE" || true

# Also show minimal diff stats for sw.js
git diff --stat -- public_html/sw.js || true

Repository: awehttam/binkterm-php

Length of output: 47


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git rev-parse --is-inside-work-tree
git status --porcelain=v1
git diff --name-only
git diff --stat
git diff -- public_html/sw.js | head -n 80 || true

Repository: awehttam/binkterm-php

Length of output: 410


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if git diff --quiet -- public_html/sw.js; then
  echo "[sw.js] no diff vs HEAD"
else
  echo "[sw.js] has diff vs HEAD"
  git diff -- public_html/sw.js | head -n 120
fi

Repository: awehttam/binkterm-php

Length of output: 89


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Identify available refs/branches to find PR base
git branch -a || true
git show-ref --heads --tags | head -n 200 || true
git remote -v || true

# Try common base refs; if they don't exist, commands will fail safely
for base in origin/main origin/master origin/develop origin/trunk; do
  if git show-ref --verify --quiet "refs/remotes/${base}"; then
    echo "=== diff vs ${base} ==="
    git diff --stat "${base}...HEAD" -- public_html/sw.js || true
    git diff "${base}...HEAD" -- public_html/sw.js | rg -n "CACHE_NAME|binkcache|v[0-9]+" || true
  fi
done

# Fallback: diff against merge-base with origin/main if present
if git show-ref --verify --quiet "refs/remotes/origin/main"; then
  mb=$(git merge-base origin/main HEAD)
  echo "=== merge-base: ${mb} diff ==="
  git diff --stat "${mb}..HEAD" -- public_html/sw.js || true
  git diff "${mb}..HEAD" -- public_html/sw.js | rg -n "CACHE_NAME|binkcache|v[0-9]+" || true
fi

Repository: awehttam/binkterm-php

Length of output: 479


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "HEAD CACHE_NAME:"
git show HEAD:public_html/sw.js | rg -n "const CACHE_NAME"

echo "origin/main CACHE_NAME:"
git show origin/main:public_html/sw.js | rg -n "const CACHE_NAME"

echo "Compare sw.js (only CACHE_NAME line diff, if any):"
git diff --unified=3 origin/main -- public_html/sw.js | rg -n "CACHE_NAME|binkcache|v[0-9]+" || true

echo "sw.js blob ids:"
echo -n "HEAD: "; git rev-parse HEAD:public_html/sw.js
echo -n "origin/main: "; git rev-parse origin/main:public_html/sw.js

Repository: awehttam/binkterm-php

Length of output: 432


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

p = Path("config/i18n/fr/common.php")
data = p.read_bytes()

# Find any UTF-8 sequences for curly/smart quotes U+2018/U+2019
# U+2018 = ’? actually U+2018 is LEFT SINGLE QUOTATION MARK (’ in many fonts) -> bytes: E2 80 98
# U+2019 = RIGHT SINGLE QUOTATION MARK -> bytes: E2 80 99
bad = []
for cp, seq in [("U+2018", b"\xE2\x80\x98"), ("U+2019", b"\xE2\x80\x99")]:
    idx = data.find(seq)
    if idx != -1:
        bad.append((cp, idx))

print("[curly_quotes_bytes_findings]")
if not bad:
    print("none")
else:
    for cp, idx in bad:
        print(cp, "at byte offset", idx)

# Also print the literal offending surrounding context (if any)
s = data.decode("utf-8", errors="replace")
for cp in ["\u2018", "\u2019"]:
    if cp in s:
        i = s.index(cp)
        print("\nFirst occurrence context for", cp, ":")
        print(s[max(0,i-80):min(len(s),i+80)])
PY

Repository: awehttam/binkterm-php

Length of output: 317


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path

path = Path("config/i18n/fr/common.php")
data = path.read_text(encoding="utf-8", errors="strict")

needle = "\u2019"  # U+2019 RIGHT SINGLE QUOTATION MARK
indices = []
start = 0
while True:
    i = data.find(needle, start)
    if i == -1:
        break
    indices.append(i)
    start = i + 1

print(f"[count] {len(indices)} occurrences of U+2019 in {path}")

# Map offsets to line numbers
line_starts = []
pos = 0
for line in data.splitlines(True):
    line_starts.append(pos)
    pos += len(line)

def offset_to_line(off):
    # find rightmost line_start <= off
    lo, hi = 0, len(line_starts)-1
    while lo <= hi:
        mid = (lo+hi)//2
        if line_starts[mid] <= off:
            lo = mid + 1
        else:
            hi = mid - 1
    line_idx = hi
    return line_idx + 1  # 1-based

for off in indices[:20]:
    ln = offset_to_line(off)
    # print the whole line trimmed + key prefix if possible
    line = data.splitlines()[ln-1]
    # try extract key inside single quotes at start of assignment
    key = None
    if "=>" in line:
        left = line.split("=>",1)[0].strip()
        if left.startswith("'") and left.endswith("'"):
            key = left[1:-1]
    print(f"\n[U+2019] line {ln} key={key!r}")
    print(line[:300])
PY

# Additionally, show whether the exact string from the review snippet contains U+2019
rg -n "l’aide|l\u2019aide" config/i18n/fr/common.php || true

Repository: awehttam/binkterm-php

Length of output: 2390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="config/i18n/fr/common.php"

# Show git diff hunks for the PGP keys that include U+2019 in the current file
git diff -U3 origin/main -- "$FILE" | rg -n "ui\\.compose\\.pgp\\.encrypt_netmail_help|ui\\.compose\\.pgp\\.encrypt_no_key_help|ui\\.echomail\\.ignore\\.|ui\\.settings\\.echomail_ignore_rules\\.|ui\\.admin\\.ads\\.form_hidden_warning|ui\\.admin\\.filearea_rules\\.no_areas" || true

python3 - <<'PY'
import re
from pathlib import Path

path = Path("config/i18n/fr/common.php")

def extract(content, key):
    # key is like ui.compose.pgp.encrypt_netmail_help
    # match: 'key' => 'value',
    m = re.search(rf"^\s*'{re.escape(key)}'\s*=>\s*'((?:\\'|[^'])*?)'\s*,\s*$", content, flags=re.M)
    return m.group(1) if m else None

keys = [
  "ui.compose.pgp.encrypt_netmail_help",
  "ui.compose.pgp.encrypt_no_key_help",
]

head = path.read_text(encoding="utf-8", errors="strict")
base = (Path(".git")/"")

import subprocess
origin = subprocess.check_output(["git","show","origin/main:config/i18n/fr/common.php"], text=True)

for k in keys:
    h = extract(head, k)
    o = extract(origin, k)
    print(f"\n== {k} ==")
    print("origin/main value:", o)
    print("HEAD value:       ", h)
PY

Repository: awehttam/binkterm-php

Length of output: 621


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="config/i18n/fr/common.php"

# Show added lines (in this PR) that contain U+2019
git diff -U0 origin/main -- "$FILE" | rg -n "\+.*\u2019|\\xE2\\x80\\x99|[’]" || true

# Specifically list which PGP-related keys in the added diff contain that character
git diff -U0 origin/main -- "$FILE" \
  | rg -n "^\+.*'ui\.(settings|compose|pgp|keyserver|echomail|address_book|admin)\.pgp\..*'|^\+.*'ui\.(compose|pgp)\.pgp\..*'|^\+.*'ui\.compose\.pgp\..*'|^\+.*'ui\.settings\.pgp\..*'|^\+.*'ui\.keyserver\..*'|^\+.*'ui\.pgp\..*'|^\+.*'ui\.address_book\.pgp_.*'|^\+.*'ui\.admin\.bbs_settings\.features\.(enable_pgp|pgp_help|enable_pgp_managed_keys|pgp_managed_keys_help)'" -n || true

Repository: awehttam/binkterm-php

Length of output: 8434


Fix prohibited curly apostrophes in the new FR PGP strings; other i18n + parity + cache work looks good.

  • i18n checks for fr pass (check_i18n_syntax.php, check_i18n_hardcoded_strings.php, check_i18n_error_keys.php).
  • The targeted new PGP keys are present in config/i18n/{en,de,es,it}/common.php (locale parity holds).
  • public_html/sw.js cache bump is present (binkcache-v908binkcache-v927).
  • config/i18n/fr/common.php contains U+2019 curly apostrophes in two new strings—replace with escaped ASCII apostrophes:
    • ui.compose.pgp.encrypt_netmail_help: l’aidel\'aide
    • ui.compose.pgp.encrypt_no_key_help: d’uned\'une
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/fr/common.php` around lines 4897 - 4983, Two French PGP strings
contain U+2019 curly apostrophes; open the i18n entries for
ui.compose.pgp.encrypt_netmail_help and ui.compose.pgp.encrypt_no_key_help and
replace the curly apostrophe characters with an escaped ASCII apostrophe (\' )
so the strings read "l\'aide" and "d\'une" respectively, then save and re-run
i18n checks.

Comment thread config/i18n/fr/common.php
Comment on lines +4950 to +4958
'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a l’aide de sa cle publique publiee.',
'ui.compose.pgp.sign_echomail' => 'Signer cet echomail',
'ui.compose.pgp.sign_echomail_help' => 'Signe le message avec votre cle privee geree stockee.',
'ui.compose.pgp.encrypt_only_notice' => 'Le chiffrement netmail a seulement besoin de la cle publique du destinataire. La signature et le dechiffrement exigent votre cle privee geree.',
'ui.compose.pgp.encrypt_recipient_label' => 'Cle publique du destinataire',
'ui.compose.pgp.encrypt_recipient_help' => 'Choisissez la cle publique qui sera utilisee pour chiffrer ce netmail.',
'ui.compose.pgp.encrypt_recipient_placeholder' => 'Selectionnez la cle publique pour le chiffrement',
'ui.compose.pgp.encrypt_no_key' => 'Aucune cle publique correspondante trouvee.',
'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant d’une cle publique publiee.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace typographic apostrophes with escaped ASCII apostrophes in catalog values.

These strings include curly apostrophes (), which are disallowed in i18n PHP catalogs.

Suggested patch
-    'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a l’aide de sa cle publique publiee.',
+    'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a l\'aide de sa cle publique publiee.',
...
-    'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant d’une cle publique publiee.',
+    'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant d\'une cle publique publiee.',

As per coding guidelines, “Any apostrophe inside a PHP string value in i18n catalogs must be written as \'” and “U+2018/U+2019 (curly/smart quotes) must never appear anywhere in a catalog file.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a laide de sa cle publique publiee.',
'ui.compose.pgp.sign_echomail' => 'Signer cet echomail',
'ui.compose.pgp.sign_echomail_help' => 'Signe le message avec votre cle privee geree stockee.',
'ui.compose.pgp.encrypt_only_notice' => 'Le chiffrement netmail a seulement besoin de la cle publique du destinataire. La signature et le dechiffrement exigent votre cle privee geree.',
'ui.compose.pgp.encrypt_recipient_label' => 'Cle publique du destinataire',
'ui.compose.pgp.encrypt_recipient_help' => 'Choisissez la cle publique qui sera utilisee pour chiffrer ce netmail.',
'ui.compose.pgp.encrypt_recipient_placeholder' => 'Selectionnez la cle publique pour le chiffrement',
'ui.compose.pgp.encrypt_no_key' => 'Aucune cle publique correspondante trouvee.',
'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant dune cle publique publiee.',
'ui.compose.pgp.encrypt_netmail_help' => 'Chiffre le message pour le destinataire a l\'aide de sa cle publique publiee.',
'ui.compose.pgp.sign_echomail' => 'Signer cet echomail',
'ui.compose.pgp.sign_echomail_help' => 'Signe le message avec votre cle privee geree stockee.',
'ui.compose.pgp.encrypt_only_notice' => 'Le chiffrement netmail a seulement besoin de la cle publique du destinataire. La signature et le dechiffrement exigent votre cle privee geree.',
'ui.compose.pgp.encrypt_recipient_label' => 'Cle publique du destinataire',
'ui.compose.pgp.encrypt_recipient_help' => 'Choisissez la cle publique qui sera utilisee pour chiffrer ce netmail.',
'ui.compose.pgp.encrypt_recipient_placeholder' => 'Selectionnez la cle publique pour le chiffrement',
'ui.compose.pgp.encrypt_no_key' => 'Aucune cle publique correspondante trouvee.',
'ui.compose.pgp.encrypt_no_key_help' => 'Saisissez un destinataire disposant d\'une cle publique publiee.',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/fr/common.php` around lines 4950 - 4958, The reported strings use
curly apostrophes (U+2019) which are forbidden; replace each curly apostrophe in
the catalog values with an escaped ASCII apostrophe (\' ) so the PHP string
literals remain valid — update the values for keys such as
ui.compose.pgp.encrypt_netmail_help, ui.compose.pgp.sign_echomail_help,
ui.compose.pgp.encrypt_only_notice, ui.compose.pgp.encrypt_recipient_help,
ui.compose.pgp.encrypt_recipient_placeholder, and
ui.compose.pgp.encrypt_no_key_help to use \' instead of ’ throughout the file.

Comment thread config/i18n/fr/errors.php
Comment on lines +65 to +68
'errors.pgp.decrypt_failed' => 'Impossible de dechiffrer le message PGP.',
'errors.pgp.recipient_required' => 'Impossible de resoudre une cle publique pour le destinataire.',
'errors.pgp.passphrase_required' => 'Saisissez votre phrase de passe PGP.',
'errors.pgp.private_key_required' => 'Une cle privee geree est requise pour signer.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix missing French accents in PGP error messages.

Several words are missing required French accents. As per coding guidelines, "use literal characters (e.g., 'é', 'à') only" when editing French catalogs.

Required corrections:

  • Line 65: dechiffrerdéchiffrer
  • Line 66: resoudrerésoudre, cleclé
  • Line 68: cleclé, priveeprivée, gereegérée
🔧 Proposed fix
-    'errors.pgp.decrypt_failed' => 'Impossible de dechiffrer le message PGP.',
-    'errors.pgp.recipient_required' => 'Impossible de resoudre une cle publique pour le destinataire.',
+    'errors.pgp.decrypt_failed' => 'Impossible de déchiffrer le message PGP.',
+    'errors.pgp.recipient_required' => 'Impossible de résoudre une clé publique pour le destinataire.',
     'errors.pgp.passphrase_required' => 'Saisissez votre phrase de passe PGP.',
-    'errors.pgp.private_key_required' => 'Une cle privee geree est requise pour signer.',
+    'errors.pgp.private_key_required' => 'Une clé privée gérée est requise pour signer.',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'errors.pgp.decrypt_failed' => 'Impossible de dechiffrer le message PGP.',
'errors.pgp.recipient_required' => 'Impossible de resoudre une cle publique pour le destinataire.',
'errors.pgp.passphrase_required' => 'Saisissez votre phrase de passe PGP.',
'errors.pgp.private_key_required' => 'Une cle privee geree est requise pour signer.',
'errors.pgp.decrypt_failed' => 'Impossible de déchiffrer le message PGP.',
'errors.pgp.recipient_required' => 'Impossible de résoudre une clé publique pour le destinataire.',
'errors.pgp.passphrase_required' => 'Saisissez votre phrase de passe PGP.',
'errors.pgp.private_key_required' => 'Une clé privée gérée est requise pour signer.',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/fr/errors.php` around lines 65 - 68, The French PGP error
messages use unaccented characters; update the translation strings for the keys
'errors.pgp.decrypt_failed', 'errors.pgp.recipient_required', and
'errors.pgp.private_key_required' to use proper French accents: change
"dechiffrer" → "déchiffrer" in errors.pgp.decrypt_failed; in
errors.pgp.recipient_required change "resoudre" → "résoudre" and "cle" → "clé";
in errors.pgp.private_key_required change "cle" → "clé", "privee" → "privée" and
"geree" → "gérée". Ensure you edit the literal string values (not escaped
entities) so the file contains the accented characters directly.

Comment thread config/i18n/fr/errors.php
Comment on lines +686 to +698
'errors.pgp.load_failed' => 'Impossible de charger les cles PGP.',
'errors.pgp.public_key_required' => 'Une cle publique est requise.',
'errors.pgp.invalid_key' => 'Cle publique PGP invalide.',
'errors.pgp.save_failed' => 'Impossible d\'enregistrer la cle PGP.',
'errors.pgp.invalid_keypair' => 'Paire de cles PGP invalide.',
'errors.pgp.key_not_found' => 'Cle PGP introuvable.',
'errors.pgp.private_key_not_found' => 'Cle privee introuvable.',
'errors.pgp.delete_failed' => 'Impossible de supprimer la cle PGP.',
'errors.pgp.disabled' => 'PGP est desactive sur ce systeme.',
'errors.pgp.managed_disabled' => 'La generation de cles PGP gerees est desactivee sur ce systeme.',
'errors.pgp.passphrase_too_short' => 'Utilisez une phrase de passe PGP plus longue.',
'errors.pgp.passphrase_mismatch' => 'La confirmation de la phrase de passe ne correspond pas.',
'errors.pgp.generation_failed' => 'Impossible de generer la cle PGP geree.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix extensive missing French accents in PGP management errors.

Nearly every line in this segment is missing required French accents. As per coding guidelines, accented catalogs must use literal characters like 'é', 'à'.

Required corrections:

  • Lines 686-693: cle/clesclé/clés (8 occurrences)
  • Line 692: priveeprivée
  • Lines 694-695: desactive/desactiveedésactivé/désactivée
  • Lines 694-695: systemesystème (2 occurrences)
  • Lines 695, 698: generation/generer/geree/gereesgénération/générer/gérée/gérées

Note: Line 689 correctly uses d\'enregistrer with escaped apostrophe, but the word "cle" still needs the accent.

🔧 Proposed fix
-    'errors.pgp.load_failed' => 'Impossible de charger les cles PGP.',
-    'errors.pgp.public_key_required' => 'Une cle publique est requise.',
-    'errors.pgp.invalid_key' => 'Cle publique PGP invalide.',
-    'errors.pgp.save_failed' => 'Impossible d\'enregistrer la cle PGP.',
-    'errors.pgp.invalid_keypair' => 'Paire de cles PGP invalide.',
-    'errors.pgp.key_not_found' => 'Cle PGP introuvable.',
-    'errors.pgp.private_key_not_found' => 'Cle privee introuvable.',
-    'errors.pgp.delete_failed' => 'Impossible de supprimer la cle PGP.',
-    'errors.pgp.disabled' => 'PGP est desactive sur ce systeme.',
-    'errors.pgp.managed_disabled' => 'La generation de cles PGP gerees est desactivee sur ce systeme.',
+    'errors.pgp.load_failed' => 'Impossible de charger les clés PGP.',
+    'errors.pgp.public_key_required' => 'Une clé publique est requise.',
+    'errors.pgp.invalid_key' => 'Clé publique PGP invalide.',
+    'errors.pgp.save_failed' => 'Impossible d\'enregistrer la clé PGP.',
+    'errors.pgp.invalid_keypair' => 'Paire de clés PGP invalide.',
+    'errors.pgp.key_not_found' => 'Clé PGP introuvable.',
+    'errors.pgp.private_key_not_found' => 'Clé privée introuvable.',
+    'errors.pgp.delete_failed' => 'Impossible de supprimer la clé PGP.',
+    'errors.pgp.disabled' => 'PGP est désactivé sur ce système.',
+    'errors.pgp.managed_disabled' => 'La génération de clés PGP gérées est désactivée sur ce système.',
     'errors.pgp.passphrase_too_short' => 'Utilisez une phrase de passe PGP plus longue.',
     'errors.pgp.passphrase_mismatch' => 'La confirmation de la phrase de passe ne correspond pas.',
-    'errors.pgp.generation_failed' => 'Impossible de generer la cle PGP geree.',
+    'errors.pgp.generation_failed' => 'Impossible de générer la clé PGP gérée.',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/i18n/fr/errors.php` around lines 686 - 698, Update the French error
strings for PGP keys to use proper accented characters: replace "cle"/"cles"
with "clé"/"clés" in keys like 'errors.pgp.load_failed',
'errors.pgp.public_key_required', 'errors.pgp.invalid_key',
'errors.pgp.save_failed', 'errors.pgp.invalid_keypair',
'errors.pgp.key_not_found', 'errors.pgp.private_key_not_found', and
'errors.pgp.delete_failed'; change "privee" to "privée" in
'errors.pgp.private_key_not_found'; change "desactive"/"desactivee" to
"désactivé"/"désactivée" and "systeme" to "système" in 'errors.pgp.disabled' and
'errors.pgp.managed_disabled'; and change
"generation"/"generer"/"geree"/"gerees" to
"génération"/"générer"/"gérée"/"gérées" in 'errors.pgp.managed_disabled' and
'errors.pgp.generation_failed', while preserving existing escaping for
apostrophes (e.g., keep d\'enregistrer).

Comment thread src/MessageHandler.php
}

$finalMessageText = $this->applyUserSignatureAndTagline($messageText, $fromUserId, $tagline);
$finalMessageText = $pgpMode ? $messageText : $this->applyUserSignatureAndTagline($messageText, $fromUserId, $tagline);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use strict boolean gating for $pgpMode.

Line 1402 and Line 1816 use loose truthiness ($pgpMode ? ...). If callers pass string values (for example 'false'), PHP treats them as truthy and incorrectly skips signature/tagline application.

Suggested fix
-        $finalMessageText = $pgpMode ? $messageText : $this->applyUserSignatureAndTagline($messageText, $fromUserId, $tagline);
+        $isPgpMode = filter_var($pgpMode, FILTER_VALIDATE_BOOLEAN);
+        $finalMessageText = $isPgpMode ? $messageText : $this->applyUserSignatureAndTagline($messageText, $fromUserId, $tagline);
-        $finalMessageText = $pgpMode ? $messageText : $this->applyUserSignatureAndTagline($messageText, $fromUserId, $tagline);
+        $isPgpMode = filter_var($pgpMode, FILTER_VALIDATE_BOOLEAN);
+        $finalMessageText = $isPgpMode ? $messageText : $this->applyUserSignatureAndTagline($messageText, $fromUserId, $tagline);

Also applies to: 1816-1816

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MessageHandler.php` at line 1402, The ternary uses loose truthiness for
$pgpMode which treats values like the string 'false' as true; change the
condition to a strict boolean check (e.g. use $pgpMode === true) when deciding
whether to skip applyUserSignatureAndTagline so only a real boolean true
disables signature/tagline; update both occurrences (the one assigning
$finalMessageText and the other at the second occurrence around line 1816) to
use === true (or an explicit is_bool check plus === true) referencing the
$pgpMode variable and the applyUserSignatureAndTagline(...) call.

Comment on lines +209 to +216
private function readOldPacketLength(string $binary, int $offset, int $lengthType): array
{
return match ($lengthType) {
0 => [ord($binary[$offset] ?? "\0"), $offset + 1],
1 => [(int)unpack('n', substr($binary, $offset, 2))[1], $offset + 2],
2 => [(int)unpack('N', substr($binary, $offset, 4))[1], $offset + 4],
default => throw new InvalidArgumentException('Indeterminate OpenPGP packet lengths are not supported.'),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file
FILE="src/Pgp/ArmoredPublicKeyParser.php"
echo "== File check =="
ls -l "$FILE"

# Show the exact function and nearby code (line range around 180-260)
echo "== readOldPacketLength() and nearby context =="
nl -ba "$FILE" | sed -n '160,260p'

# Find call sites of readOldPacketLength()
echo "== Call sites =="
rg -n "readOldPacketLength\s*\(" "$FILE"

# Show the function that calls parsePackets/length readers (search parsePackets)
echo "== parsePackets() =="
rg -n "function\s+parsePackets|parsePackets\s*\(" "$FILE"
# Print around parsePackets if found
LINE=$(rg -n "function\s+parsePackets" "$FILE" | head -n1 | cut -d: -f1 || true)
if [ -n "${LINE:-}" ]; then
  START=$((LINE-60))
  END=$((LINE+120))
  nl -ba "$FILE" | sed -n "${START},${END}p"
fi

# Search for any existing buffer-length guards or exception handling
echo "== Buffer/length guards near decoding =="
rg -n "(strlen\(|substr\(|unpack\(|offset|lengthType|truncated|InvalidArgumentException|throw)" "$FILE"

Repository: awehttam/binkterm-php

Length of output: 275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/Pgp/ArmoredPublicKeyParser.php"

echo "== File check =="
ls -l "$FILE"

echo "== readOldPacketLength() context (lines 180-260) =="
python3 - <<'PY'
import itertools
path = "src/Pgp/ArmoredPublicKeyParser.php"
start, end = 180, 260
with open(path, 'r', encoding='utf-8') as f:
    for i, line in enumerate(f, start=1):
        if start <= i <= end:
            print(f"{i:5d}\t{line.rstrip()}")
        if i > end:
            break
PY

echo "== Call sites of readOldPacketLength() =="
rg -n "readOldPacketLength\s*\(" "$FILE" || true

echo "== parsePackets() context (search and dump nearby) =="
LINE=$(rg -n "function\s+parsePackets|parsePackets\s*\(" "$FILE" | head -n1 | cut -d: -f1 || true)
echo "First parsePackets-related line: ${LINE:-<none>}"
if [ -n "${LINE:-}" ]; then
  python3 - <<'PY'
path="src/Pgp/ArmoredPublicKeyParser.php"
target_line=int("""'"${LINE:-0}"'""")
start=max(1,target_line-80)
end=target_line+180
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if start<=i<=end:
            print(f"{i:5d}\t{line.rstrip()}")
        if i>end:
            break
PY
fi

echo "== Decoding/length guards around substr/unpack calls =="
rg -n "(unpack\s*\(|substr\s*\(|strlen\s*\(|InvalidArgumentException|throw|offset\s*[+\-]?=|lengthType)" "$FILE"

Repository: awehttam/binkterm-php

Length of output: 3308


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/Pgp/ArmoredPublicKeyParser.php"

echo "== Call site context (lines 110-190) =="
python3 - <<'PY'
path="src/Pgp/ArmoredPublicKeyParser.php"
start,end=110,190
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if start<=i<=end:
            print(f"{i:5d}\t{line.rstrip()}")
        if i>end:
            break
PY

echo "== File header/top (lines 1-120) =="
python3 - <<'PY'
path="src/Pgp/ArmoredPublicKeyParser.php"
start,end=1,120
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if start<=i<=end:
            print(f"{i:5d}\t{line.rstrip()}")
        if i>end:
            break
PY

echo "== Searching for existing truncated-length guards =="
rg -n "Truncated OpenPGP packet length|Partial OpenPGP packet lengths|unpack\\('n'|unpack\\('N'|substr\\(\\$binary, \\$offset, (2|4)" "$FILE" || true

echo "== Searching for pre-check on offset/buffer length before readOldPacketLength =="
rg -n "readOldPacketLength\\(|\\$offset\\s*[+\\-]=|strlen\\(\\$binary\\)" "$FILE"

Repository: awehttam/binkterm-php

Length of output: 7775


Reject truncated old-format packet lengths before calling unpack().
parsePackets() only checks ($offset + $packetLength) > $length after readOldPacketLength() returns; for old-format lengthType 1/2, readOldPacketLength() calls unpack() on substr($binary, $offset, 2|4) without ensuring those bytes exist, so malformed/truncated input can trigger PHP warnings/failures before your InvalidArgumentException path runs.

Suggested fix
     private function readOldPacketLength(string $binary, int $offset, int $lengthType): array
     {
         return match ($lengthType) {
-            0 => [ord($binary[$offset] ?? "\0"), $offset + 1],
-            1 => [(int)unpack('n', substr($binary, $offset, 2))[1], $offset + 2],
-            2 => [(int)unpack('N', substr($binary, $offset, 4))[1], $offset + 4],
+            0 => isset($binary[$offset])
+                ? [ord($binary[$offset]), $offset + 1]
+                : throw new InvalidArgumentException('Truncated OpenPGP packet length.'),
+            1 => ($offset + 2) <= strlen($binary)
+                ? [(int) unpack('n', substr($binary, $offset, 2))[1], $offset + 2]
+                : throw new InvalidArgumentException('Truncated OpenPGP packet length.'),
+            2 => ($offset + 4) <= strlen($binary)
+                ? [(int)unpack('N', substr($binary, $offset, 4))[1], $offset + 4]
+                : throw new InvalidArgumentException('Truncated OpenPGP packet length.'),
             default => throw new InvalidArgumentException('Indeterminate OpenPGP packet lengths are not supported.'),
         };
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Pgp/ArmoredPublicKeyParser.php` around lines 209 - 216,
readOldPacketLength currently calls unpack() for old-format lengthType 1 and 2
without verifying the input contains the required bytes, which can cause PHP
warnings on truncated input; update readOldPacketLength (and reference
parsePackets behavior) to first validate that ($offset + 2) <= strlen($binary)
for lengthType 1 and ($offset + 4) <= strlen($binary) for lengthType 2 and throw
an InvalidArgumentException (or similar) if the bytes are missing, otherwise
proceed to unpack and return the length and new offset as before.

Comment thread src/PgpKeyService.php
Comment on lines +200 to +239
if ($search !== '') {
$qualified = $this->parseQualifiedLookupSearch($search);
if ($qualified !== null) {
$identityLike = '%' . $qualified['identity'] . '%';
$params = [$identityLike, $identityLike, $identityLike, $identityLike];

if ($qualified['type'] === 'host') {
$sql .= "
WHERE (
LOWER(u.username) LIKE LOWER(?)
OR LOWER(u.real_name) LIKE LOWER(?)
OR LOWER(COALESCE(k.user_id_string, '')) LIKE LOWER(?)
OR LOWER(COALESCE(k.label, '')) LIKE LOWER(?)
)
";
} else {
$sql .= "
WHERE LOWER(COALESCE(u.fidonet_address, '')) = LOWER(?)
AND (
LOWER(u.username) LIKE LOWER(?)
OR LOWER(u.real_name) LIKE LOWER(?)
OR LOWER(COALESCE(k.user_id_string, '')) LIKE LOWER(?)
OR LOWER(COALESCE(k.label, '')) LIKE LOWER(?)
)
";
array_unshift($params, $qualified['network']);
}
} else {
$normalizedFingerprint = strtoupper(ltrim($search, '0x'));
$sql .= "
WHERE k.fingerprint = ?
OR LOWER(u.username) LIKE LOWER(?)
OR LOWER(u.real_name) LIKE LOWER(?)
OR LOWER(COALESCE(k.email, '')) LIKE LOWER(?)
OR LOWER(COALESCE(k.user_id_string, '')) LIKE LOWER(?)
OR LOWER(COALESCE(k.label, '')) LIKE LOWER(?)
";
$like = '%' . $search . '%';
$params = [$normalizedFingerprint, $like, $like, $like, $like, $like];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Host-qualified lookups ignore the host and swallow email searches.

parseQualifiedLookupSearch() classifies any alice@example.com string as type=host, but both host branches only compare alice and never validate example.com. That makes alice@wrong-host return the same result as alice, and exact email queries stop using the k.email predicate entirely.

Also applies to: 275-281, 316-417

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PgpKeyService.php` around lines 200 - 239, The host-qualified branch
built when parseQualifiedLookupSearch() returns $qualified with type 'host'
ignores the domain and drops the email predicate; update the SQL and params so
the host (domain) is validated and email searches still work: when
$qualified['type'] === 'host' add a domain check (e.g. compare LOWER(k.email)
ends-with or LOWER(SUBSTR(k.email, INSTR(...))) = LOWER($qualified['host']) or
use LIKE '%@' . $qualified['host']) and include the domain parameter in $params
alongside the existing identity-like params; similarly ensure the other branches
(the non-qualified fingerprint/email branch and the other occurrences
referenced) keep or reintroduce the k.email predicate and adjust param ordering
to match the new placeholders so exact email queries still match k.email while
host-qualified lookups validate the domain.

Comment thread src/PgpLookupService.php
Comment on lines +564 to +570
if (filter_var($network, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
|| filter_var($network, FILTER_VALIDATE_IP) !== false) {
return [
'identity' => $identity,
'network' => strtolower($network),
'type' => 'host',
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't accept arbitrary host/IP targets from user-controlled keyserver queries.

This branch turns identity@hostname and even identity@IP into live outbound requests, and the new public /keyserver route feeds raw search input straight into this service. That makes the feature an SSRF primitive against localhost, RFC1918, and link-local addresses. Restrict web-exposed lookups to FTN destinations (or an explicit allowlist), and if host-mode is kept for trusted CLI use, also reject private/reserved resolutions and redirects before issuing the request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PgpLookupService.php` around lines 564 - 570, The current branch in
PgpLookupService that returns a host-type when filter_var($network, ...) matches
allows web requests (via the public /keyserver route) to trigger arbitrary
outbound host/IP lookups — make the service refuse host-mode for public HTTP
requests and only allow FTN-style lookups or explicit allowlisted hosts: detect
caller context (e.g. PHP_SAPI or the request origin) and if the caller is a
web/public request, do not enter the host branch and instead return an error or
force FTN lookup; if you must retain host-mode for trusted CLI use, gate the
branch on a trusted-context check and harden validation of $network by rejecting
loopback, RFC1918/private, link-local and reserved IPs and any hostnames that
resolve to those addresses, and perform safe redirect checks before issuing
outbound requests (log and return a safe error on rejection).

Comment thread templates/compose.twig
{% else %}
const interestEchoareas = null;
{% endif %}
window.currentUser = {{ current_user|json_encode(15)|raw }};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for where current_user is set in routes that render compose.twig
rg -n -C5 'current_user' routes/web-routes.php routes/api-routes.php | head -80

# Also check if there's a dedicated method that prepares user data for templates
rg -n 'getCurrentUser|currentUser' src/

Repository: awehttam/binkterm-php

Length of output: 4539


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect compose.twig around line 476 to confirm variable origin/type
compose_file="$(fd -a -t f 'compose\.twig$' templates | head -n 1 || true)"
echo "Compose file: $compose_file"
nl -ba "$compose_file" | sed -n '440,520p'

# 2) Inspect Template.php addGlobalVariables sanitization for current_user
nl -ba src/Template.php | sed -n '1,240p'
nl -ba src/Template.php | sed -n '160,220p'
nl -ba src/Template.php | sed -n '170,210p'

# 3) Inspect Auth::getCurrentUser for what it returns
nl -ba src/Auth.php | sed -n '120,260p'

# 4) Search for any other sanitization of current_user fields
rg -n "unset\\(\\$currentUser\\['" src/Template.php src/Auth.php src -S || true
rg -n "addGlobal\\('current_user'|addGlobal\\('currentUser'|current_user\\b" src -S || true

Repository: awehttam/binkterm-php

Length of output: 182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

compose_file="$(fd -a -t f 'compose\.twig$' templates | head -n 1 || true)"
echo "Compose file: $compose_file"
cat -n "$compose_file" | sed -n '440,520p'

echo "---- src/Template.php (sanitization around current_user globals) ----"
# Show the portion where current_user is unset/sanitized and added as a Twig global
cat -n src/Template.php | sed -n '1,220p'
echo "---- src/Template.php (addGlobalVariables) ----"
cat -n src/Template.php | sed -n '80,220p'
echo "---- src/Template.php (unset password_hash) ----"
# Narrow further if it exists
rg -n "unset\\(\\$currentUser\\['password_hash'\\]\\)" -n src/Template.php || true
cat -n src/Template.php | sed -n '160,210p'

echo "---- src/Auth.php (getCurrentUser return shape) ----"
cat -n src/Auth.php | sed -n '130,260p'
echo "---- src/Auth.php (full getCurrentUser function header area) ----"
# Print a bit more around the function definition
rg -n "function getCurrentUser\\(" src/Auth.php || true
AUTH_LN="$(rg -n "function getCurrentUser\\(" src/Auth.php | head -n1 | cut -d: -f1 || true)"
if [[ -n "${AUTH_LN:-}" ]]; then
  start=$((AUTH_LN-20))
  end=$((AUTH_LN+220))
  cat -n src/Auth.php | sed -n "${start},${end}p"
fi

echo "---- Search for any other sanitization of current_user ----"
rg -n "unset\\(\\$currentUser\\['" src/Template.php src/Auth.php src -S || true
rg -n "addGlobal\\('current_user'|addGlobal\\(\"current_user\"|addGlobal\\('currentUser'" src/Template.php src -S || true

Repository: awehttam/binkterm-php

Length of output: 23911


🏁 Script executed:

#!/bin/bash
set -e

# 1) Extract Auth::getCurrentUser implementation
rg -n "function getCurrentUser\\(" -n src/Auth.php
AUTH_LN="$(rg -n "function getCurrentUser\\(" src/Auth.php | head -n1 | cut -d: -f1)"
start=$((AUTH_LN-30))
end=$((AUTH_LN+220))
echo "---- src/Auth.php around getCurrentUser (lines ${start}-${end}) ----"
cat -n src/Auth.php | sed -n "${start},${end}p"

# 2) Search for what keys Auth::getCurrentUser returns (array construction patterns)
echo "---- token-like / secret-like key mentions in Auth.php ----"
rg -n "(password_hash|csrf_token|session|token|remember|api_key|secret|auth|jwt)" src/Auth.php || true

# 3) Search for additional sanitization of current_user anywhere else
echo "---- other sanitization of currentUser in src/Template.php ----"
cat -n src/Template.php | sed -n '150,220p'
rg -n "unset\\(\\$currentUser\\['" src/Template.php || true

echo "---- all occurrences of adding current_user global ----"
rg -n "addGlobal\\('current_user'|addGlobal\\(\"current_user\"" src/Template.php src -S || true

Repository: awehttam/binkterm-php

Length of output: 21640


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect validateSession in src/Auth.php (what fields are returned as the user array)
rg -n "function validateSession\\(" src/Auth.php
VAL_LN="$(rg -n "function validateSession\\(" src/Auth.php | head -n1 | cut -d: -f1)"
start=$((VAL_LN-40))
end=$((VAL_LN+140))
echo "---- src/Auth.php around validateSession (lines ${start}-${end}) ----"
cat -n src/Auth.php | sed -n "${start},${end}p"

# 2) Search for how window.currentUser is used
compose_file="$(fd -a -t f 'compose\.twig$' templates | head -n 1)"
echo "---- usage of window.currentUser in compose.twig ----"
rg -n "window\\.currentUser|currentUser\\." "$compose_file"

echo "---- usage of currentUser in JS under web root (best-effort) ----"
# Try to limit scope; only inspect likely JS files referenced by compose.twig
# (avoid large search)
fd -t f -e js js templates | head -n 2 >/dev/null 2>&1 || true
rg -n "window\\.currentUser|currentUser\\." -S js src templates | head -n 200 || true

# 3) List keys present in the current_user array after Template.php sanitization (heuristic)
# Can't execute PHP; instead extract selected column names from validateSession SQL
echo "---- columns selected in validateSession SQL ----"
rg -n "SELECT[\\s\\S]*FROM user_sessions|FROM user_sessions s" -n src/Auth.php || true

Repository: awehttam/binkterm-php

Length of output: 10278


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find where compose.twig is rendered
rg -n "compose\\.twig" routes src templates || true

# 2) Inspect the relevant part of routes/web-routes.php around where templateVars set current_user
rg -n "templateVars = \\[" routes/web-routes.php
# show around the previously found lines (approx 1725-1760)
cat -n routes/web-routes.php | sed -n '1710,1765p'

# 3) Inspect how templates are rendered (Template class render/response path)
rg -n "twig->render\\(|render\\(" src/Template.php src/WebDoorController.php routes/web-routes.php || true
# show Template.php render-related methods
rg -n "function .*render" src/Template.php || true
cat -n src/Template.php | sed -n '220,420p'

# 4) Check if render merges templateVars over globals or if it uses globals only
rg -n "addGlobal\\(|getTemplate|render\\(" src/Template.php || true

Repository: awehttam/binkterm-php

Length of output: 21957


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect route around compose.twig render
cat -n routes/web-routes.php | sed -n '1860,1950p'

# Inspect Template.php renderResponse to see whether variables override globals as expected
rg -n "function renderResponse\\(" -n src/Template.php
RENDER_LN="$(rg -n "function renderResponse\\(" src/Template.php | head -n1 | cut -d: -f1)"
start=$((RENDER_LN-30))
end=$((RENDER_LN+80))
cat -n src/Template.php | sed -n "${start},${end}p"

Repository: awehttam/binkterm-php

Length of output: 8720


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the compose route block (around the line we saw rendering compose.twig)
rg -n "compose\\.twig" routes/web-routes.php
# Print more context above the renderResponse call
cat -n routes/web-routes.php | sed -n '1650,1935p'

echo "---- Inspect RouteHelper::requireAuth ----"
rg -n "function requireAuth\\(" src/RouteHelper.php src -S || true
# Show the definition
RB_LN="$(rg -n "function requireAuth\\(" src/RouteHelper.php | head -n1 | cut -d: -f1)"
if [[ -n "${RB_LN:-}" ]]; then
  start=$((RB_LN-40)); end=$((RB_LN+120))
  cat -n src/RouteHelper.php | sed -n "${start},${end}p"
fi

echo "---- Search for any sanitization of password_hash before passing templateVars['current_user'] ----"
rg -n "password_hash" routes/web-routes.php routes/api-routes.php src/Template.php src/RouteHelper.php src/Auth.php || true

Repository: awehttam/binkterm-php

Length of output: 18589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect RouteHelper::requireAuth implementation
cat -n src/RouteHelper.php | sed -n '1,140p'

# Also inspect Auth.php validateSession return usage (ensure no sanitization elsewhere)
rg -n "password_hash" src/RouteHelper.php src/Auth.php src/Template.php || true

Repository: awehttam/binkterm-php

Length of output: 4367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "password_hash" routes/web-routes.php templates src | head -n 200
rg -n "unset\\(\\$.*password_hash\\)" routes/web-routes.php src templates || true

Repository: awehttam/binkterm-php

Length of output: 1656


Sanitize current_user before exposing it as window.currentUser

templates/compose.twig assigns window.currentUser from the template variable current_user, which is passed from routes/web-routes.php as $templateVars['current_user'] = $user.

That $user originates from Auth::validateSession() and includes u.password_hash (and also email, about_me, last_login, etc.). While src/Template.php unsets password_hash for the Twig global current_user, it is bypassed here because the route-provided context variable current_user takes precedence.

Remove password_hash (and any other sensitive fields) from the user payload before assigning it to $templateVars['current_user'] / before rendering compose.twig (e.g., return a “safe user” DTO from Auth, or explicitly unset sensitive keys in the compose route).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@templates/compose.twig` at line 476, The template exposes full user data via
window.currentUser (window.currentUser = {{ current_user|json_encode(15)|raw
}}), which can leak sensitive fields from Auth::validateSession() such as
password_hash; fix by ensuring the route that sets $templateVars['current_user']
(in routes/web-routes.php) supplies a sanitized/safe-user DTO or explicitly
unsets sensitive keys before rendering compose.twig (e.g., remove password_hash,
email, last_login, about_me as needed) so compose.twig only receives
non-sensitive fields; alternatively modify the Auth call that returns the user
to return a sanitized array/object for use in templates.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@routes/api-routes.php`:
- Around line 12961-12962: The fallback for QR generation currently uses
$node['public_key'] ?? $node['node_id'] which only handles null; change the
logic so an empty string is treated as missing by checking and normalizing
$node['public_key'] (e.g., trim and is_empty) before assignment: set $publicKey
to $node['node_id'] when $node['public_key'] is null or an empty/whitespace
string, then call $service->getQrCodeSvg((string)$handle, (string)$publicKey).

In `@tests/explain_echomail_list_queries.php`:
- Around line 133-139: The filter SQL fragments and their bound params are
appended in different orders: buildEchomailIgnoreFilter('em') SQL is
concatenated to $filterClause before buildModerationVisibilityFilter('em') SQL,
but when assembling $pageParams/$totalParams/$unreadParams you place
$filterParams (which currently contains moderationFilter['params']) before
ignoreFilter['params'], causing mismatched bindings; to fix, ensure the params
are merged in the same order as the SQL by appending ignoreFilter['params']
first then moderationFilter['params'] when building $filterParams and when
merging into $pageParams, $totalParams and $unreadParams so the parameter order
matches the order of $filterClause (refer to buildEchomailIgnoreFilter,
buildModerationVisibilityFilter, $filterClause, $filterParams, $pageParams,
$totalParams, $unreadParams).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0dd306eb-ee08-40f1-b230-8221c0bb2e2a

📥 Commits

Reviewing files that changed from the base of the PR and between e6c0d10 and 6925352.

📒 Files selected for processing (10)
  • database/migrations/v20260606034151_add_meshcore_node_adverts.sql
  • docs/PacketBBS.md
  • public_html/js/meshcore-node-info.js
  • public_html/webdoors/cwn/api.php
  • routes/api-routes.php
  • routes/packetbbs-routes.php
  • routes/web-routes.php
  • src/PacketBbs/MeshcoreAdvertService.php
  • src/PacketBbs/PacketBbsNodeService.php
  • tests/explain_echomail_list_queries.php
✅ Files skipped from review due to trivial changes (1)
  • docs/PacketBBS.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • routes/web-routes.php

Comment thread routes/api-routes.php
Comment on lines +12961 to +12962
$publicKey = $node['public_key'] ?? $node['node_id'];
echo $service->getQrCodeSvg((string)$handle, (string)$publicKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat blank public_key as missing before generating the QR.

This only falls back on null. If an existing/backfilled MeshCore row has public_key = '', the QR will encode an empty key instead of the promised node_id fallback, so the deep link is broken for that node.

Suggested fix
-        $publicKey = $node['public_key'] ?? $node['node_id'];
-        echo $service->getQrCodeSvg((string)$handle, (string)$publicKey);
+        $publicKey = trim((string)($node['public_key'] ?? ''));
+        if ($publicKey === '') {
+            $publicKey = (string)$node['node_id'];
+        }
+        echo $service->getQrCodeSvg((string)$handle, $publicKey);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$publicKey = $node['public_key'] ?? $node['node_id'];
echo $service->getQrCodeSvg((string)$handle, (string)$publicKey);
$publicKey = trim((string)($node['public_key'] ?? ''));
if ($publicKey === '') {
$publicKey = (string)$node['node_id'];
}
echo $service->getQrCodeSvg((string)$handle, $publicKey);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@routes/api-routes.php` around lines 12961 - 12962, The fallback for QR
generation currently uses $node['public_key'] ?? $node['node_id'] which only
handles null; change the logic so an empty string is treated as missing by
checking and normalizing $node['public_key'] (e.g., trim and is_empty) before
assignment: set $publicKey to $node['node_id'] when $node['public_key'] is null
or an empty/whitespace string, then call $service->getQrCodeSvg((string)$handle,
(string)$publicKey).

Comment on lines +133 to +139
$ignoreFilter = $handler->buildEchomailIgnoreFilter($userId, 'em');
$filterClause .= $ignoreFilter['sql'];
$moderationFilter = $handler->buildModerationVisibilityFilter($userId, 'em');
$filterClause .= $moderationFilter['sql'];
foreach ($moderationFilter['params'] as $param) {
$filterParams[] = $param;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Parameter binding order mismatch for filter clauses.

In buildFilterState, ignoreFilter['sql'] is appended to filterClause before moderationFilter['sql'] (lines 134, 136). However, when building $pageParams (line 219), $filterParams (containing moderationFilter['params']) is placed before $ignoreFilter['params'].

This causes parameter order to be incorrect when both filters produce placeholders, leading to wrong bind values in EXPLAIN output.

Proposed fix
 function buildFilterState(MessageHandler $handler, PDO $db, int $userId, string $filter): array
 {
     $filterClause = '';
     $filterParams = [];
+    $ignoreParams = [];
 
     if ($filter === 'unread') {
         $filterClause = ' AND mrs.read_at IS NULL';
     } elseif ($filter === 'read') {
         $filterClause = ' AND mrs.read_at IS NOT NULL';
     } elseif ($filter === 'tome') {
         $user = fetchOne($db, 'SELECT username, real_name FROM users WHERE id = ?', [$userId]);
         if ($user) {
             $filterClause = ' AND (LOWER(em.to_name) = LOWER(?) OR LOWER(em.to_name) = LOWER(?))';
             $filterParams[] = $user['username'];
             $filterParams[] = $user['real_name'];
         }
     } elseif ($filter === 'saved') {
         $filterClause = ' AND sav.id IS NOT NULL';
     }
 
     $filterClause .= " AND (em.date_written IS NULL OR em.date_written <= (NOW() AT TIME ZONE 'UTC'))";
     $ignoreFilter = $handler->buildEchomailIgnoreFilter($userId, 'em');
     $filterClause .= $ignoreFilter['sql'];
+    $ignoreParams = $ignoreFilter['params'];
     $moderationFilter = $handler->buildModerationVisibilityFilter($userId, 'em');
     $filterClause .= $moderationFilter['sql'];
     foreach ($moderationFilter['params'] as $param) {
         $filterParams[] = $param;
     }
 
     return [
         'filterClause' => $filterClause,
         'filterParams' => $filterParams,
-        'ignoreFilter' => $ignoreFilter,
+        'ignoreParams' => $ignoreParams,
     ];
 }

Then update the param merging:

 $filterState = buildFilterState($handler, $db, $userId, $filter);
 $filterClause = $filterState['filterClause'];
 $filterParams = $filterState['filterParams'];
-$ignoreFilter = $filterState['ignoreFilter'];
+$ignoreParams = $filterState['ignoreParams'];
 
-$pageParams = array_merge($pageParams, $echoareaIds, $filterParams, $ignoreFilter['params'], [$limit, $offset]);
+$pageParams = array_merge($pageParams, $echoareaIds, $filterParams, $ignoreParams, [$limit, $offset]);

And similar updates for $totalParams and $unreadParams.

Also applies to: 189-192, 218-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/explain_echomail_list_queries.php` around lines 133 - 139, The filter
SQL fragments and their bound params are appended in different orders:
buildEchomailIgnoreFilter('em') SQL is concatenated to $filterClause before
buildModerationVisibilityFilter('em') SQL, but when assembling
$pageParams/$totalParams/$unreadParams you place $filterParams (which currently
contains moderationFilter['params']) before ignoreFilter['params'], causing
mismatched bindings; to fix, ensure the params are merged in the same order as
the SQL by appending ignoreFilter['params'] first then
moderationFilter['params'] when building $filterParams and when merging into
$pageParams, $totalParams and $unreadParams so the parameter order matches the
order of $filterClause (refer to buildEchomailIgnoreFilter,
buildModerationVisibilityFilter, $filterClause, $filterParams, $pageParams,
$totalParams, $unreadParams).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant