From 1b4e6a2c89d50832bbe543d85ad0771bd106542a Mon Sep 17 00:00:00 2001 From: awehttam Date: Wed, 4 Mar 2026 17:58:16 -0800 Subject: [PATCH 001/251] Bump to 1.8.6 and update PWA/upgrade docs --- README.md | 6 +++++- docs/UPGRADING_1.8.6.md | 26 ++++++++++++++++++++++++++ routes/web-routes.php | 14 +++++++++++++- src/Version.php | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 docs/UPGRADING_1.8.6.md diff --git a/README.md b/README.md index 6ff03442..9b7ba4ca 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ We're looking for experienced PHP developers interested in contributing to Binkt ## Table of Contents +- [Contributors Wanted](#-contributors-wanted) - [Screen shots](#screen-shots) - [Features](#features) - [Installation](#installation) @@ -34,11 +35,14 @@ We're looking for experienced PHP developers interested in contributing to Binkt - [Customization](#customization) - [Security Considerations](#security-considerations) - [File Areas](#file-areas) - - [File Area Rules](#file-area-rules) +- [File Area Rules](#file-area-rules) +- [Authentication Flow](#authentication-flow) +- [API Specification](#api-specification) - [Native Doors](#native-doors---native-linux--windows-door-programs) - [DOS Doors](#dos-doors---classic-bbs-door-games) - [WebDoors](#webdoors---web-based-door-games) - [Gemini Support](#gemini-support) +- [Frequently Asked Questions](#frequently-asked-questions) - [Developer Guide](#developer-guide) - [Contributing](#contributing) - [License](#license) diff --git a/docs/UPGRADING_1.8.6.md b/docs/UPGRADING_1.8.6.md new file mode 100644 index 00000000..2d9477d9 --- /dev/null +++ b/docs/UPGRADING_1.8.6.md @@ -0,0 +1,26 @@ +# Upgrading to 1.8.6 + +Make sure you've made a backup of your database and files before upgrading. + +## Summary of Changes + +**Improvements** +- PWA manifest: added app shortcuts for Doors (`/games`) and Files (`/files`) + +## Upgrade Instructions + +### From Git + +```bash +git pull +php scripts/setup.php +scripts/restart_daemons.sh +``` + +### Using the Installer + +```bash +wget https://raw.githubusercontent.com/awehttam/binkterm-php-installer/main/binkterm-installer.phar +php binkterm-installer.phar +scripts/restart_daemons.sh +``` diff --git a/routes/web-routes.php b/routes/web-routes.php index 63c60f90..7183fc57 100644 --- a/routes/web-routes.php +++ b/routes/web-routes.php @@ -122,7 +122,19 @@ "short_name": "Nodelist", "description":"Browse the nodelist", "url":"/nodelist" - } + }, + { + "name": "Doors", + "short_name": "Doors", + "description":"Browse doors and games", + "url":"/games" + }, + { + "name": "Files", + "short_name": "Files", + "description":"Browse files", + "url":"/files" + } ] } diff --git a/src/Version.php b/src/Version.php index e54b1cc1..de1b7c75 100644 --- a/src/Version.php +++ b/src/Version.php @@ -31,7 +31,7 @@ class Version * This should be updated when releasing new versions. * Format: MAJOR.MINOR.PATCH */ - private const VERSION = '1.8.5'; + private const VERSION = '1.8.6'; /** * Get the current application version From 943bae386a65ea4c292b81c2fd6079389a93f168 Mon Sep 17 00:00:00 2001 From: awehttam Date: Wed, 4 Mar 2026 20:21:55 -0800 Subject: [PATCH 002/251] Add i18n phase 0 foundation and API error_code migration --- config/i18n/en/common.php | 22 + config/i18n/en/errors.php | 43 ++ .../v1.10.18_add_user_locale_preference.sql | 13 + docs/proposals/Translations.md | 201 ++++++++ public_html/js/app.js | 162 +++++- routes/admin-routes.php | 157 +++--- routes/api-routes.php | 481 ++++++++++-------- src/Auth.php | 10 +- src/I18n/LocaleResolver.php | 149 ++++++ src/I18n/Translator.php | 234 +++++++++ src/MessageHandler.php | 18 +- src/Template.php | 37 +- templates/base.twig | 7 +- templates/old.base.twig | 7 +- templates/settings.twig | 16 +- templates/shells/bbs-menu/base.twig | 7 +- templates/shells/web/base.twig | 7 +- 17 files changed, 1244 insertions(+), 327 deletions(-) create mode 100644 config/i18n/en/common.php create mode 100644 config/i18n/en/errors.php create mode 100644 database/migrations/v1.10.18_add_user_locale_preference.sql create mode 100644 docs/proposals/Translations.md create mode 100644 src/I18n/LocaleResolver.php create mode 100644 src/I18n/Translator.php diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php new file mode 100644 index 00000000..08fa86b2 --- /dev/null +++ b/config/i18n/en/common.php @@ -0,0 +1,22 @@ + 'Soon', + 'time.in_hours' => 'In {count} hour{suffix}', + 'time.tomorrow' => 'Tomorrow', + 'time.in_days' => 'In {count} days', + 'time.just_now' => 'Just now', + 'time.minutes_ago' => '{count} minutes ago', + 'time.hours_ago' => '{count} hour{suffix} ago', + 'time.yesterday' => 'Yesterday', + 'time.days_ago' => '{count} days ago', + 'time.suffix_plural' => 's', + 'time.suffix_singular' => '', + + // Shared/common UI defaults. + 'errors.failed_load_messages' => 'Failed to load messages', + 'messages.none_found' => 'No messages found', + 'messages.no_subject' => '(No Subject)', +]; + diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php new file mode 100644 index 00000000..79a0f35f --- /dev/null +++ b/config/i18n/en/errors.php @@ -0,0 +1,43 @@ + 'Authentication required', + 'errors.auth.invalid_csrf_token' => 'Invalid CSRF token', + 'errors.auth.missing_credentials' => 'Username and password required', + 'errors.auth.invalid_credentials' => 'Invalid credentials', + 'errors.auth.invalid_api_key' => 'Invalid API key', + 'errors.auth.gateway_token_missing_fields' => 'userid and token are required', + 'errors.auth.invalid_or_expired_gateway_token' => 'Invalid or expired token', + 'errors.auth.username_or_email_required' => 'Username or email is required', + 'errors.auth.token_required' => 'Token is required', + 'errors.auth.invalid_or_expired_token' => 'Invalid or expired token', + 'errors.auth.token_and_password_required' => 'Token and new password are required', + + // Registration + 'errors.register.invalid_submission' => 'Invalid submission', + 'errors.register.too_fast' => 'Please take your time filling out the form.', + 'errors.register.session_expired' => 'Session expired. Please refresh the page and try again.', + 'errors.register.rate_limited' => 'Too many registration attempts. Please try again later.', + 'errors.register.required_fields' => 'Username, password, and real name are required', + 'errors.register.invalid_username_format' => 'Username must be 3-20 characters, letters, numbers, and underscores only', + 'errors.register.restricted_name' => 'This username or real name is not allowed', + 'errors.register.weak_password' => 'Password must be at least 8 characters long', + 'errors.register.user_exists' => 'A user with this username or name already exists. Please try logging in or contact the sysop for assistance.', + 'errors.register.failed' => 'Registration failed. Please try again later.', + + // Reminder + 'errors.reminder.username_required' => 'Username is required', + 'errors.reminder.user_not_found_or_logged_in' => 'User not found or already logged in', + 'errors.reminder.send_failed' => 'Failed to send reminder. Please try again later.', + + // Settings + 'errors.settings.invalid_input' => 'Invalid input', + 'errors.settings.update_failed' => 'Failed to update settings', + 'errors.settings.exception' => 'Failed to update settings', + + // Messages + 'errors.messages.share_create_failed' => 'Failed to create share link', + 'errors.messages.share_lookup_failed' => 'Failed to load share links', + 'errors.messages.share_revoke_failed' => 'Failed to revoke share link', +]; diff --git a/database/migrations/v1.10.18_add_user_locale_preference.sql b/database/migrations/v1.10.18_add_user_locale_preference.sql new file mode 100644 index 00000000..b69a5ebd --- /dev/null +++ b/database/migrations/v1.10.18_add_user_locale_preference.sql @@ -0,0 +1,13 @@ +-- Migration: 1.10.18 - Add locale preference to user settings +-- Created: 2026-03-05 + +ALTER TABLE user_settings + ADD COLUMN IF NOT EXISTS locale VARCHAR(16) DEFAULT 'en'; + +UPDATE user_settings +SET locale = 'en' +WHERE locale IS NULL OR BTRIM(locale) = ''; + +ALTER TABLE user_settings + ALTER COLUMN locale SET DEFAULT 'en'; + diff --git a/docs/proposals/Translations.md b/docs/proposals/Translations.md new file mode 100644 index 00000000..a82a33a3 --- /dev/null +++ b/docs/proposals/Translations.md @@ -0,0 +1,201 @@ +# Translation Support Plan + +> Draft notice: This proposal is a draft generated by AI and may not have been reviewed for accuracy. + +## Goal +Add multi-language support (i18n/l10n) to the web application without a destabilizing rewrite, while preserving current behavior for English users. + +## Current State Summary +- No translation framework is installed or wired into Twig/PHP/JS. +- User-facing copy is hardcoded across Twig templates, inline scripts, standalone JS files, and API JSON error messages. +- Locale formatting is partially configurable (`date_format`, `timezone`) but inconsistent and still contains English-only relative time text. +- `` is fixed to English and PWA manifest strings are English-only. + +## Scope +- In scope: + - Web UI (Twig templates, inline JS, `public_html/js/*.js`). + - API response localization strategy. + - Date/time/number formatting consistency by locale. + - Locale selection, persistence, and fallback. + - Translation key management, extraction, QA, and CI checks. +- Out of scope (initial rollout): + - Translating user-generated content (messages, posts, ads). + - Auto-translation services. + - Telnet/BinkP protocol payload text. + +## Guiding Principles +- Preserve API stability while introducing localization safely. +- Prefer stable message/error codes over localized server text in APIs. +- Keep English as fallback for all missing keys. +- Ship incrementally by page area, not by “convert everything at once”. + +## Proposed Architecture +1. Locale Resolution +- Resolution order: + 1. User setting (`user_settings.locale`) when authenticated. + 2. Cookie/session locale for anonymous users. + 3. `Accept-Language` best match. + 4. Default locale (`en`). +- Persist locale choice in user settings and anonymous cookie. + +2. Translation Source of Truth +- Store catalogs in `config/i18n/.php` (associative arrays) or JSON files. +- Naming convention for keys: + - `nav.login`, `nav.logout` + - `dashboard.loading_shouts` + - `errors.auth.invalid_credentials` + - `admin.users.not_found` + +3. Server Translator Service +- Add `src/I18n/Translator.php`: + - `translate($key, array $params = [], ?string $locale = null): string` + - fallback chain locale -> base locale -> `en` -> key. +- Add Twig function/filter: + - `t('key')` and optional parameter interpolation. + +4. Frontend Translator +- Expose active locale and subset dictionaries via: + - global `window.i18n` + - or `/api/i18n/catalog?ns=...`. +- Add JS helper: + - `t(key, params)` for UI strings. +- Move repeated strings from inline JS to keys. + +5. API Message Strategy +- Replace direct English API error text with: + - machine code (`error_code`) + - optional default English message for backward compatibility window. +- Frontend maps `error_code` -> localized text. +- Keep transitional compatibility: + - return both fields for one release cycle: `{ error_code, error }`. + +6. Formatting Standardization +- Centralize date/time formatting in JS helper using user locale + timezone. +- Replace hardcoded `en-US` and direct string literals in relative-time functions with keyed translations. + +## Data Model Changes +1. Migration +- Add `locale` column to `user_settings` (default `en`). +- Optional: keep existing `date_format` temporarily; map to locale where possible. + +2. Backward Compatibility +- If no `locale` set, infer from current `date_format` where feasible: + - `en-US` -> `en-US` + - `en-GB` -> `en-GB` + - etc. +- Fall back to `en`. + +## Implementation Phases +## Phase 0: Foundation (No User-Visible Language Changes) +- Add translator service and locale resolver. +- Wire translator into `Template` and Twig (`t()` helper). +- Add `locale` setting support in backend and settings API. +- Add JS i18n helper and dictionary loading. +- Add `lang` variable to layout templates (``). +- Acceptance: + - App behavior unchanged in English. + - Locale can be set and read end-to-end. + +## Phase 1: Shared Shell/UI Chrome +- Convert common templates first: + - `templates/base.twig` + - `templates/shells/web/base.twig` + - `templates/shells/bbs-menu/base.twig` + - `templates/old.base.twig` +- Convert global app JS status/error labels. +- Acceptance: + - Navigation/footer/common modals translated. + - No missing key placeholders in shared layout. + +## Phase 2: High-Traffic User Pages +- Convert: + - `dashboard.twig`, `netmail.twig`, `echomail.twig`, `compose.twig`, `settings.twig`, `login/register` flows. +- Replace inline alert/confirm/loading strings with keys. +- Acceptance: + - Core message workflows localized. + - Date/time text uses locale consistently. + +## Phase 3: API Error Code Migration +- Introduce `error_code` for major API endpoints: + - auth, messaging, polls, shoutbox, files, admin. +- Frontend consumes codes; fallback to legacy `error` during transition. +- Acceptance: + - No frontend dependency on raw English `error` strings. + +## Phase 4: Admin Surface +- Convert admin templates and admin JS panels incrementally. +- Prioritize high-use pages (users, dashboard, binkp config, stats). +- Acceptance: + - Admin core workflows localized. + +## Phase 5: Hardening and Cleanup +- Remove deprecated English fallback fields in APIs (after compatibility window). +- Add lint/check scripts: + - detect hardcoded English in Twig/JS (allowlist exceptions). + - detect missing translation keys. +- Add snapshot/integration tests for at least `en` + one secondary locale. + +## Work Breakdown (Concrete File Targets) +1. Core +- `src/Template.php` (inject locale and `t()` support) +- `src/MessageHandler.php` (persist/read locale in user settings) +- new `src/I18n/Translator.php` +- new `src/I18n/LocaleResolver.php` + +2. Routes/API +- `routes/api-routes.php` (error code rollout) +- `routes/admin-routes.php` (error code rollout) +- `routes/door-routes.php`, `src/Auth.php` (auth/error consistency) +- `routes/web-routes.php` (manifest strings and locale-aware metadata) + +3. Templates +- Shared bases first, then user pages, then admin pages. + +4. Frontend JS +- `public_html/js/app.js` +- `public_html/js/netmail.js` +- `public_html/js/echomail.js` +- page-local inline scripts in Twig templates. + +## Risks and Mitigations +1. Risk: Massive key churn and merge conflicts. +- Mitigation: namespace keys by feature and convert by phase. + +2. Risk: API clients break if `error` format changes. +- Mitigation: transition period with both `error_code` and `error`. + +3. Risk: Missing keys shipped to production. +- Mitigation: build-time key validation + runtime fallback logging. + +4. Risk: UI overflow/layout regressions in longer languages. +- Mitigation: targeted visual QA on nav, buttons, modals, tables. + +## Testing Strategy +- Unit tests: + - translator fallback and interpolation. + - locale resolver precedence. +- Integration tests: + - login/settings locale persistence. + - key pages render in `en` and secondary locale. +- API tests: + - verify `error_code` contract and fallback. +- Manual QA: + - dashboard, compose, message listing, admin user actions. + +## Definition of Done (Project-Level) +- Locale selectable and persisted. +- Shared UI + core user flows localized. +- API uses stable error codes (with migration complete). +- No critical hardcoded English in in-scope pages. +- CI checks for missing keys/hardcoded strings active. + +## Effort Estimate +- Foundation + shared UI: medium. +- Core user pages + JS conversion: medium-high. +- API error-code migration + admin conversion + test hardening: high. +- Overall: high effort, low-to-medium operational risk if phased as above. + +## Rollout Recommendation +1. Ship `en` + one additional locale first. +2. Enable locale switch behind feature flag for internal testing. +3. Expand locale coverage after API code migration stabilizes. diff --git a/public_html/js/app.js b/public_html/js/app.js index b5b285e3..e6e0b1d8 100644 --- a/public_html/js/app.js +++ b/public_html/js/app.js @@ -415,6 +415,114 @@ function toggleKludgeLines() { // Global user settings object window.userSettings = {}; +// Lightweight i18n client state with lazy namespace loading. +window.i18n = window.i18n || { + locale: window.appLocale || 'en', + defaultLocale: window.appDefaultLocale || 'en', + catalogs: {}, + loadedNamespaces: {} +}; + +function i18nInterpolate(template, params = {}) { + let output = String(template || ''); + Object.keys(params || {}).forEach(function(key) { + const token = new RegExp(`\\{${key}\\}`, 'g'); + output = output.replace(token, String(params[key])); + }); + return output; +} + +function i18nLookup(key) { + const catalogs = window.i18n?.catalogs || {}; + for (const ns of Object.keys(catalogs)) { + if (Object.prototype.hasOwnProperty.call(catalogs[ns], key)) { + return catalogs[ns][key]; + } + } + return null; +} + +function t(key, params = {}, fallback = '') { + const value = i18nLookup(key); + if (typeof value === 'string') { + return i18nInterpolate(value, params); + } + if (fallback) { + return i18nInterpolate(fallback, params); + } + return key; +} + +window.t = t; + +function getApiErrorMessage(payload, fallback = 'An unexpected error occurred.') { + if (!payload || typeof payload !== 'object') { + return fallback; + } + if (payload.error_code) { + return t(payload.error_code, {}, payload.error || fallback); + } + if (payload.error) { + return String(payload.error); + } + return fallback; +} + +window.getApiErrorMessage = getApiErrorMessage; + +function mergeCatalogs(catalogs) { + if (!catalogs || typeof catalogs !== 'object') { + return; + } + Object.keys(catalogs).forEach(function(ns) { + if (!window.i18n.catalogs[ns]) { + window.i18n.catalogs[ns] = {}; + } + Object.assign(window.i18n.catalogs[ns], catalogs[ns] || {}); + window.i18n.loadedNamespaces[ns] = true; + }); +} + +function loadI18nNamespaces(namespaces = []) { + const normalized = (namespaces || []) + .map(ns => String(ns || '').trim()) + .filter(ns => ns.length > 0); + if (normalized.length === 0) { + return Promise.resolve(); + } + + const missing = normalized.filter(ns => !window.i18n.loadedNamespaces[ns]); + if (missing.length === 0) { + return Promise.resolve(); + } + + const nsParam = encodeURIComponent(missing.join(',')); + const localeParam = encodeURIComponent(window.i18n.locale || window.appLocale || 'en'); + + return fetch(`/api/i18n/catalog?ns=${nsParam}&locale=${localeParam}`) + .then(function(response) { + if (!response.ok) { + throw new Error('i18n catalog load failed'); + } + return response.json(); + }) + .then(function(payload) { + if (!payload || !payload.success) { + throw new Error('invalid i18n payload'); + } + if (payload.locale) { + window.i18n.locale = payload.locale; + } + if (payload.default_locale) { + window.i18n.defaultLocale = payload.default_locale; + } + mergeCatalogs(payload.catalogs || {}); + }) + .catch(function() { + // Non-fatal in Phase 0: app keeps English literals as fallback. + }); +} + $(document).ready(function() { // Load user settings on page load loadUserSettings(); @@ -476,10 +584,14 @@ function loadUserSettings() { window.userSettings = response; console.log('Loaded user settings (legacy format):', window.userSettings); } - - // Apply font settings after loading - applyFontSettings(); - resolve(window.userSettings); + + window.i18n.locale = window.userSettings.locale || window.appLocale || window.i18n.locale || 'en'; + + loadI18nNamespaces(window.appI18nNamespaces || ['common']).finally(function() { + // Apply font settings after loading + applyFontSettings(); + resolve(window.userSettings); + }); }) .fail(function() { console.log('Failed to load user settings, using defaults'); @@ -491,14 +603,18 @@ function loadUserSettings() { quote_coloring: true, default_sort: 'date_desc', timezone: 'America/Los_Angeles', + locale: window.appLocale || 'en', font_family: 'Courier New, Monaco, Consolas, monospace', font_size: 16, signature_text: '' }; - - // Apply font settings after loading defaults - applyFontSettings(); - resolve(window.userSettings); + + window.i18n.locale = window.userSettings.locale || window.appLocale || 'en'; + loadI18nNamespaces(window.appI18nNamespaces || ['common']).finally(function() { + // Apply font settings after loading defaults + applyFontSettings(); + resolve(window.userSettings); + }); }); }); } @@ -638,26 +754,34 @@ function formatDate(dateString) { const absDays = Math.abs(diffDays); const absHours = Math.abs(diffHours); if (absDays === 0 && absHours === 0) { - return 'Soon'; + return t('time.soon', {}, 'Soon'); } else if (absDays === 0) { - return `In ${absHours} hour${absHours !== 1 ? 's' : ''}`; + return t('time.in_hours', { + count: absHours, + suffix: absHours !== 1 ? t('time.suffix_plural', {}, 's') : t('time.suffix_singular', {}, '') + }, `In ${absHours} hour${absHours !== 1 ? 's' : ''}`); } else if (absDays === 1) { - return 'Tomorrow'; + return t('time.tomorrow', {}, 'Tomorrow'); } else { - return `In ${absDays} days`; + return t('time.in_days', { count: absDays }, `In ${absDays} days`); } } if (diffDays === 0) { if (diffHours === 0) { const diffMins = Math.floor(diffMs / (1000 * 60)); - return diffMins <= 1 ? 'Just now' : `${diffMins} minutes ago`; + return diffMins <= 1 + ? t('time.just_now', {}, 'Just now') + : t('time.minutes_ago', { count: diffMins }, `${diffMins} minutes ago`); } - return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + return t('time.hours_ago', { + count: diffHours, + suffix: diffHours !== 1 ? t('time.suffix_plural', {}, 's') : t('time.suffix_singular', {}, '') + }, `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`); } else if (diffDays === 1) { - return 'Yesterday'; + return t('time.yesterday', {}, 'Yesterday'); } else if (diffDays < 7) { - return `${diffDays} days ago`; + return t('time.days_ago', { count: diffDays }, `${diffDays} days ago`); } else { // Use user's preferred date format for older dates const userDateFormat = window.userSettings?.date_format || 'en-US'; @@ -711,7 +835,7 @@ function loadMessages(type, area = null, page = 1) { updatePagination(data.pagination); }) .fail(function() { - showError('Failed to load messages'); + showError(t('errors.failed_load_messages', {}, 'Failed to load messages')); }); } @@ -720,7 +844,7 @@ function displayMessages(messages, type) { let html = ''; if (messages.length === 0) { - html = '
No messages found
'; + html = `
${t('messages.none_found', {}, 'No messages found')}
`; } else { messages.forEach(function(msg) { html += ` @@ -733,7 +857,7 @@ function displayMessages(messages, type) { ${type === 'echomail' ? formatFullDate(msg.date_written) : formatDate(msg.date_written)} -
${escapeHtml(msg.subject || '(No Subject)')}
+
${escapeHtml(msg.subject || t('messages.no_subject', {}, '(No Subject)'))}
${msg.to_name ? `To: ${escapeHtml(msg.to_name)}` : ''} ${!msg.is_read && type === 'netmail' ? 'NEW' : ''} diff --git a/routes/admin-routes.php b/routes/admin-routes.php index 23ab6b61..c7b33f76 100644 --- a/routes/admin-routes.php +++ b/routes/admin-routes.php @@ -11,6 +11,19 @@ use BinktermPHP\WebDoorManifest; use Pecee\SimpleRouter\SimpleRouter; +if (!function_exists('apiError')) { + function apiError(string $errorCode, string $message, ?int $status = null, array $extra = []): void + { + if ($status !== null) { + http_response_code($status); + } + echo json_encode(array_merge([ + 'error_code' => $errorCode, + 'error' => $message, + ], $extra)); + } +} + SimpleRouter::group(['prefix' => '/admin'], function() { // Admin dashboard @@ -260,7 +273,7 @@ echo json_encode(['user' => $userData]); } else { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); } }); @@ -280,7 +293,7 @@ echo json_encode(['success' => true, 'user_id' => $userId]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -300,7 +313,7 @@ echo json_encode(['success' => $result]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -319,7 +332,7 @@ echo json_encode(['success' => $result]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -484,7 +497,7 @@ $db->rollBack(); } http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -552,7 +565,7 @@ $db->rollBack(); } http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -572,7 +585,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -642,7 +655,7 @@ echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -739,7 +752,7 @@ echo json_encode(['success' => true, 'config' => $updated]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -757,7 +770,7 @@ echo json_encode(['success' => true, 'data' => $data]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -794,7 +807,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -838,7 +851,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -881,7 +894,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -908,7 +921,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -967,7 +980,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -989,7 +1002,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1004,7 +1017,7 @@ echo json_encode(['success' => true, 'html' => $html]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1018,7 +1031,7 @@ echo json_encode(['success' => true, 'files' => $files]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1028,19 +1041,19 @@ try { if (empty($_FILES['file'])) { http_response_code(400); - echo json_encode(['error' => 'No file uploaded']); + apiError('errors.generic', ); return; } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); - echo json_encode(['error' => 'Upload error: ' . $file['error']]); + apiError('errors.generic', ); return; } // Max 512 KB for ANSI art if ($file['size'] > 524288) { http_response_code(400); - echo json_encode(['error' => 'File too large (max 512 KB)']); + apiError('errors.generic', ); return; } $originalName = basename($file['name']); @@ -1050,7 +1063,7 @@ echo json_encode(['success' => true, 'name' => $result['name'] ?? $originalName]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1061,7 +1074,7 @@ $name = basename($name); if (!preg_match('/^[a-zA-Z0-9_\-]+\.(ans|asc|txt)$/i', $name)) { http_response_code(400); - echo json_encode(['error' => 'Invalid filename']); + apiError('errors.generic', ); return; } $client = new \BinktermPHP\Admin\AdminDaemonClient(); @@ -1069,7 +1082,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1088,7 +1101,7 @@ echo json_encode(['success' => true, 'taglines' => $result['text'] ?? '']); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1109,7 +1122,7 @@ echo json_encode(['success' => true, 'taglines' => $result['text'] ?? '']); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1129,7 +1142,7 @@ echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1163,7 +1176,7 @@ echo json_encode(['success' => true, 'config' => $savedConfig]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1188,7 +1201,7 @@ echo json_encode(['success' => true, 'message' => 'MRC daemon restart initiated']); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1207,7 +1220,7 @@ echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1228,7 +1241,7 @@ echo json_encode(['success' => true, 'config' => $updated]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1247,7 +1260,7 @@ echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1268,7 +1281,7 @@ echo json_encode(['success' => true, 'config' => $updated]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1306,7 +1319,7 @@ echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1352,7 +1365,7 @@ echo json_encode(['success' => true, 'config' => $updated]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1371,7 +1384,7 @@ echo json_encode(['success' => true, 'config' => $updated]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1580,7 +1593,7 @@ echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1601,7 +1614,7 @@ echo json_encode(['success' => true, 'config' => $updated]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1621,7 +1634,7 @@ echo json_encode(['ads' => $ads]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1636,28 +1649,28 @@ if (!isset($_FILES['ad_file'])) { http_response_code(400); - echo json_encode(['error' => 'No file uploaded']); + apiError('errors.generic', ); return; } $file = $_FILES['ad_file']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); - echo json_encode(['error' => 'Upload failed']); + apiError('errors.generic', ); return; } $maxSize = 1024 * 1024; if (!empty($file['size']) && $file['size'] > $maxSize) { http_response_code(400); - echo json_encode(['error' => 'File is too large (max 1MB)']); + apiError('errors.generic', ); return; } $content = @file_get_contents($file['tmp_name']); if ($content === false) { http_response_code(400); - echo json_encode(['error' => 'Failed to read upload']); + apiError('errors.generic', ); return; } @@ -1669,7 +1682,7 @@ echo json_encode(['success' => true, 'ad' => $ad]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1688,7 +1701,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['name' => '[A-Za-z0-9._-]+']); @@ -1724,7 +1737,7 @@ echo json_encode(['success' => true, 'id' => (int)$roomId]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1771,7 +1784,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1804,7 +1817,7 @@ echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1841,7 +1854,7 @@ echo json_encode(['success' => true, 'id' => $nodeId]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1861,7 +1874,7 @@ echo json_encode(['success' => $result]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1880,7 +1893,7 @@ echo json_encode(['success' => $result]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1931,7 +1944,7 @@ echo json_encode(['success' => $result]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1950,7 +1963,7 @@ echo json_encode(['success' => $result]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2030,7 +2043,7 @@ echo json_encode(['templates' => $templates]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2050,7 +2063,7 @@ echo json_encode($template); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2072,7 +2085,7 @@ echo json_encode($result); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2092,7 +2105,7 @@ echo json_encode($result); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2114,7 +2127,7 @@ echo json_encode($result); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); }); @@ -2159,7 +2172,7 @@ if (!$feed) { http_response_code(404); - echo json_encode(['error' => 'Feed not found']); + apiError('errors.generic', ); return; } @@ -2178,14 +2191,14 @@ // Validate required fields if (empty($input['feed_url']) || empty($input['echoarea_id']) || empty($input['post_as_user_id'])) { http_response_code(400); - echo json_encode(['error' => 'Missing required fields']); + apiError('errors.generic', ); return; } // Validate URL if (!filter_var($input['feed_url'], FILTER_VALIDATE_URL)) { http_response_code(400); - echo json_encode(['error' => 'Invalid feed URL']); + apiError('errors.generic', ); return; } @@ -2194,7 +2207,7 @@ $stmt->execute([$input['echoarea_id']]); if (!$stmt->fetch()) { http_response_code(400); - echo json_encode(['error' => 'Invalid echo area']); + apiError('errors.generic', ); return; } @@ -2203,7 +2216,7 @@ $stmt->execute([$input['post_as_user_id']]); if (!$stmt->fetch()) { http_response_code(400); - echo json_encode(['error' => 'Invalid user ID']); + apiError('errors.generic', ); return; } @@ -2237,9 +2250,9 @@ } catch (PDOException $e) { http_response_code(400); if (strpos($e->getMessage(), 'duplicate key') !== false) { - echo json_encode(['error' => 'This feed URL already exists']); + apiError('errors.generic', ); } else { - echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); + apiError('errors.generic', ); } } }); @@ -2260,14 +2273,14 @@ if (!$existingFeed) { http_response_code(404); - echo json_encode(['error' => 'Feed not found']); + apiError('errors.generic', ); return; } // Validate required fields if (empty($input['feed_url']) || empty($input['echoarea_id']) || empty($input['post_as_user_id'])) { http_response_code(400); - echo json_encode(['error' => 'Missing required fields']); + apiError('errors.generic', ); return; } @@ -2304,7 +2317,7 @@ echo json_encode(['success' => true]); } catch (PDOException $e) { http_response_code(400); - echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2322,7 +2335,7 @@ if (!$feed) { http_response_code(404); - echo json_encode(['error' => 'Feed not found']); + apiError('errors.generic', ); return; } @@ -2353,7 +2366,7 @@ if (!$feed) { http_response_code(404); - echo json_encode(['error' => 'Feed not found']); + apiError('errors.generic', ); return; } @@ -2367,7 +2380,7 @@ if ($returnCode !== 0) { http_response_code(500); - echo json_encode(['error' => 'Feed check failed', 'output' => implode("\n", $output)]); + apiError('errors.generic', ); return; } @@ -2449,7 +2462,7 @@ try { $db->query("SELECT 1 FROM user_activity_log LIMIT 1"); } catch (\Exception $e) { - echo json_encode(['error' => 'Activity log table not yet available. Run setup.php to apply migrations.']); + apiError('errors.generic', ); return; } diff --git a/routes/api-routes.php b/routes/api-routes.php index dc6e672e..93160626 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -6,6 +6,8 @@ use BinktermPHP\Auth; use BinktermPHP\Config; use BinktermPHP\Database; +use BinktermPHP\I18n\LocaleResolver; +use BinktermPHP\I18n\Translator; use BinktermPHP\MessageHandler; use BinktermPHP\RouteHelper; use BinktermPHP\UserCredit; @@ -40,6 +42,19 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): return $safe; } +if (!function_exists('apiError')) { + function apiError(string $errorCode, string $message, ?int $status = null, array $extra = []): void + { + if ($status !== null) { + http_response_code($status); + } + echo json_encode(array_merge([ + 'error_code' => $errorCode, + 'error' => $message, + ], $extra)); + } +} + SimpleRouter::group(['prefix' => '/api'], function() { /** @@ -67,8 +82,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $service = $input['service'] ?? 'web'; if (empty($username) || empty($password)) { - http_response_code(400); - echo json_encode(['error' => 'Username and password required']); + apiError('errors.auth.missing_credentials', 'Username and password required', 400); return; } @@ -100,8 +114,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } echo json_encode(['success' => true, 'csrf_token' => $csrfToken]); } else { - http_response_code(401); - echo json_encode(['error' => 'Invalid credentials']); + apiError('errors.auth.invalid_credentials', 'Invalid credentials', 401); } }); @@ -128,8 +141,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($expectedKey) || $apiKey !== $expectedKey) { //error_log($expectedKey." != ".$apiKey); - http_response_code(401); - echo json_encode(['valid' => false, 'error' => 'Invalid API key']); + apiError('errors.auth.invalid_api_key', 'Invalid API key', 401, ['valid' => false]); return; } @@ -138,8 +150,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $token = $input['token'] ?? ''; if (empty($userId) || empty($token)) { - http_response_code(400); - echo json_encode(['valid' => false, 'error' => 'userid and token are required']); + apiError('errors.auth.gateway_token_missing_fields', 'userid and token are required', 400, ['valid' => false]); return; } @@ -154,10 +165,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ]); } else { //error_log("Invalid or expired token userId=$userId, token=$token" ); - echo json_encode([ - 'valid' => false, - 'error' => 'Invalid or expired token' - ]); + apiError('errors.auth.invalid_or_expired_gateway_token', 'Invalid or expired token', 400, ['valid' => false]); } }); @@ -191,8 +199,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $usernameOrEmail = $input['usernameOrEmail'] ?? ''; if (empty($usernameOrEmail)) { - http_response_code(400); - echo json_encode(['error' => 'Username or email is required']); + apiError('errors.auth.username_or_email_required', 'Username or email is required', 400); return; } @@ -209,8 +216,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $token = $input['token'] ?? ''; if (empty($token)) { - http_response_code(400); - echo json_encode(['valid' => false, 'error' => 'Token is required']); + apiError('errors.auth.token_required', 'Token is required', 400, ['valid' => false]); return; } @@ -220,7 +226,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($tokenData) { echo json_encode(['valid' => true]); } else { - echo json_encode(['valid' => false, 'error' => 'Invalid or expired token']); + apiError('errors.auth.invalid_or_expired_token', 'Invalid or expired token', 400, ['valid' => false]); } }); @@ -232,8 +238,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $newPassword = $input['newPassword'] ?? ''; if (empty($token) || empty($newPassword)) { - http_response_code(400); - echo json_encode(['error' => 'Token and new password are required']); + apiError('errors.auth.token_and_password_required', 'Token and new password are required', 400); return; } @@ -271,8 +276,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Anti-spam validation 1: Honeypot field (skip for telnet) if (!$isTelnetRegistration && !empty($data['website'])) { // Silent rejection - don't tell bots why they failed - http_response_code(400); - echo json_encode(['error' => 'Invalid submission']); + apiError('errors.register.invalid_submission', 'Invalid submission', 400); return; } @@ -284,15 +288,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($timeTaken < 3) { // Too fast - likely a bot - http_response_code(400); - echo json_encode(['error' => 'Please take your time filling out the form.']); + apiError('errors.register.too_fast', 'Please take your time filling out the form.', 400); return; } if ($timeTaken > 1800) { // 30 minutes - session likely expired - http_response_code(400); - echo json_encode(['error' => 'Session expired. Please refresh the page and try again.']); + apiError('errors.register.session_expired', 'Session expired. Please refresh the page and try again.', 400); return; } } @@ -313,8 +315,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $rateLimitResult = $rateLimitStmt->fetch(); if ($rateLimitResult && $rateLimitResult['attempt_count'] >= 3) { - http_response_code(429); - echo json_encode(['error' => 'Too many registration attempts. Please try again later.']); + apiError('errors.register.rate_limited', 'Too many registration attempts. Please try again later.', 429); return; } @@ -342,29 +343,25 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Validate required fields if (empty($username) || empty($password) || empty($realName)) { - http_response_code(400); - echo json_encode(['error' => 'Username, password, and real name are required']); + apiError('errors.register.required_fields', 'Username, password, and real name are required', 400); return; } // Validate username format if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) { - http_response_code(400); - echo json_encode(['error' => 'Username must be 3-20 characters, letters, numbers, and underscores only']); + apiError('errors.register.invalid_username_format', 'Username must be 3-20 characters, letters, numbers, and underscores only', 400); return; } if (\BinktermPHP\UserRestrictions::isRestrictedUsername($username) || \BinktermPHP\UserRestrictions::isRestrictedRealName($realName)) { - http_response_code(400); - echo json_encode(['error' => 'This username or real name is not allowed']); + apiError('errors.register.restricted_name', 'This username or real name is not allowed', 400); return; } // Validate password length if (strlen($password) < 8) { - http_response_code(400); - echo json_encode(['error' => 'Password must be at least 8 characters long']); + apiError('errors.register.weak_password', 'Password must be at least 8 characters long', 400); return; } @@ -382,8 +379,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $checkStmt->execute([$username, $realName, $username, $realName, $username, $realName, $username, $realName]); if ($checkStmt->fetch()) { - http_response_code(409); - echo json_encode(['error' => 'A user with this username or name already exists. Please try logging in or contact the sysop for assistance.']); + apiError('errors.register.user_exists', 'A user with this username or name already exists. Please try logging in or contact the sysop for assistance.', 409); return; } @@ -465,8 +461,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { error_log("Registration error: " . $e->getMessage()); - http_response_code(500); - echo json_encode(['error' => 'Registration failed. Please try again later.']); + apiError('errors.register.failed', 'Registration failed. Please try again later.', 500); } }); @@ -478,8 +473,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Validate required fields if (empty($username)) { - http_response_code(400); - echo json_encode(['error' => 'Username is required']); + apiError('errors.reminder.username_required', 'Username is required', 400); return; } @@ -488,8 +482,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Check if user exists and hasn't logged in if (!$handler->canSendReminder($username)) { - http_response_code(404); - echo json_encode(['error' => 'User not found or already logged in']); + apiError('errors.reminder.user_not_found_or_logged_in', 'User not found or already logged in', 404); return; } @@ -503,14 +496,12 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): 'email_sent' => $result['email_sent'] ?? false ]); } else { - http_response_code(400); - echo json_encode(['error' => $result['error']]); + apiError('errors.reminder.send_failed', (string)($result['error'] ?? 'Failed to send reminder.'), 400); } } catch (Exception $e) { error_log("Account reminder error: " . $e->getMessage()); - http_response_code(500); - echo json_encode(['error' => 'Failed to send reminder. Please try again later.']); + apiError('errors.reminder.send_failed', 'Failed to send reminder. Please try again later.', 500); } }); @@ -522,7 +513,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(400); - echo json_encode(['error' => 'Invalid user']); + apiError('errors.generic', ); return; } @@ -554,7 +545,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(400); - echo json_encode(['error' => 'Invalid user']); + apiError('errors.generic', ); return; } @@ -562,7 +553,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $state = $input['state'] ?? null; if (!is_array($state)) { http_response_code(400); - echo json_encode(['error' => 'State payload required']); + apiError('errors.generic', ); return; } @@ -594,7 +585,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $isAdmin = !empty($user['is_admin']); if (!$userId) { http_response_code(400); - echo json_encode(['error' => 'Invalid user']); + apiError('errors.generic', ); return; } @@ -602,7 +593,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $target = strtolower((string)($input['target'] ?? '')); if (!in_array($target, ['netmail', 'echomail', 'chat'], true)) { http_response_code(400); - echo json_encode(['error' => 'Invalid target']); + apiError('errors.generic', ); return; } @@ -827,7 +818,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $optionId = isset($payload['option_id']) ? (int)$payload['option_id'] : 0; if ($optionId <= 0) { http_response_code(400); - echo json_encode(['error' => 'Option is required']); + apiError('errors.generic', ); return; } @@ -837,7 +828,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $poll = $pollStmt->fetch(); if (!$poll) { http_response_code(404); - echo json_encode(['error' => 'Poll not found']); + apiError('errors.generic', ); return; } @@ -845,7 +836,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $optionStmt->execute([$optionId, $id]); if (!$optionStmt->fetch()) { http_response_code(400); - echo json_encode(['error' => 'Invalid option']); + apiError('errors.generic', ); return; } @@ -857,7 +848,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $insertStmt->execute([$id, $optionId, $userId]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => 'You have already voted in this poll.']); + apiError('errors.generic', ); return; } @@ -878,19 +869,19 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($question)) { http_response_code(400); - echo json_encode(['error' => 'Question is required']); + apiError('errors.generic', ); return; } if (strlen($question) < 10 || strlen($question) > 500) { http_response_code(400); - echo json_encode(['error' => 'Question must be 10-500 characters']); + apiError('errors.generic', ); return; } if (!is_array($options) || count($options) < 2 || count($options) > 10) { http_response_code(400); - echo json_encode(['error' => 'Must provide 2-10 options']); + apiError('errors.generic', ); return; } @@ -900,12 +891,12 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $trimmed = trim($option); if (empty($trimmed)) { http_response_code(400); - echo json_encode(['error' => 'All options must have text']); + apiError('errors.generic', ); return; } if (strlen($trimmed) > 200) { http_response_code(400); - echo json_encode(['error' => 'Options must be under 200 characters']); + apiError('errors.generic', ); return; } $cleanOptions[] = $trimmed; @@ -914,7 +905,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Check for duplicate options if (count($cleanOptions) !== count(array_unique($cleanOptions))) { http_response_code(400); - echo json_encode(['error' => 'Options must be unique']); + apiError('errors.generic', ); return; } @@ -976,7 +967,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ); http_response_code(500); - echo json_encode(['error' => 'Failed to create poll: ' . $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1019,13 +1010,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($message === '') { http_response_code(400); - echo json_encode(['error' => 'Message is required']); + apiError('errors.generic', ); return; } if (mb_strlen($message) > 280) { http_response_code(400); - echo json_encode(['error' => 'Message too long']); + apiError('errors.generic', ); return; } @@ -1072,7 +1063,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\BbsConfig::isFeatureEnabled('chat')) { http_response_code(403); - echo json_encode(['error' => 'Chat is disabled']); + apiError('errors.generic', ); return; } @@ -1096,7 +1087,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\BbsConfig::isFeatureEnabled('chat')) { http_response_code(403); - echo json_encode(['error' => 'Chat is disabled']); + apiError('errors.generic', ); return; } $auth = new Auth(); @@ -1126,7 +1117,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\BbsConfig::isFeatureEnabled('chat')) { http_response_code(403); - echo json_encode(['error' => 'Chat is disabled']); + apiError('errors.generic', ); return; } @@ -1139,7 +1130,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$userId || ($roomId && $dmUserId) || (!$roomId && !$dmUserId)) { http_response_code(400); - echo json_encode(['error' => 'Invalid chat target']); + apiError('errors.generic', ); return; } @@ -1210,7 +1201,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\BbsConfig::isFeatureEnabled('chat')) { http_response_code(403); - echo json_encode(['error' => 'Chat is disabled']); + apiError('errors.generic', ); return; } @@ -1222,13 +1213,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$userId || ($roomId && $toUserId) || (!$roomId && !$toUserId)) { http_response_code(400); - echo json_encode(['error' => 'Invalid chat target']); + apiError('errors.generic', ); return; } if ($body === '' || strlen($body) > 1000) { http_response_code(400); - echo json_encode(['error' => 'Message must be between 1 and 1000 characters']); + apiError('errors.generic', ); return; } @@ -1388,7 +1379,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $roomStmt->execute([$roomId]); if (!$roomStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'Chat room not found']); + apiError('errors.generic', ); return; } @@ -1402,7 +1393,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): error_log('[CHAT SEND] ban_hit=' . ($banHit ? '1' : '0')); if ($banHit) { http_response_code(403); - echo json_encode(['error' => 'You are banned from this room']); + apiError('errors.generic', ); return; } } else { @@ -1410,7 +1401,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userStmt->execute([$toUserId]); if (!$userStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } } @@ -1439,7 +1430,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$result) { error_log('[CHAT SEND] insert blocked by ban'); http_response_code(403); - echo json_encode(['error' => 'You are banned from this room']); + apiError('errors.generic', ); return; } @@ -1459,13 +1450,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\BbsConfig::isFeatureEnabled('chat')) { http_response_code(403); - echo json_encode(['error' => 'Chat is disabled']); + apiError('errors.generic', ); return; } if (empty($user['is_admin'])) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -1476,7 +1467,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$roomId || !$targetUserId || !in_array($action, ['kick', 'ban'], true)) { http_response_code(400); - echo json_encode(['error' => 'Invalid moderation request']); + apiError('errors.generic', ); return; } @@ -1485,7 +1476,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $roomStmt->execute([$roomId]); if (!$roomStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'Chat room not found']); + apiError('errors.generic', ); return; } @@ -1493,7 +1484,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userStmt->execute([$targetUserId]); if (!$userStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } @@ -1544,7 +1535,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\BbsConfig::isFeatureEnabled('chat')) { http_response_code(403); - echo json_encode(['error' => 'Chat is disabled']); + apiError('errors.generic', ); return; } @@ -1736,7 +1727,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -1751,7 +1742,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['echoarea' => $echoarea]); } else { http_response_code(404); - echo json_encode(['error' => 'Echo area not found']); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -1760,7 +1751,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -1815,7 +1806,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -1824,7 +1815,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -1880,7 +1871,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -1889,7 +1880,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -1917,7 +1908,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -1967,7 +1958,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['filearea' => $filearea]); } else { http_response_code(404); - echo json_encode(['error' => 'File area not found']); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -1985,7 +1976,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2003,7 +1994,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -2020,7 +2011,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -2041,7 +2032,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2050,7 +2041,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $areaId = $_GET['area_id'] ?? null; if (!$areaId) { http_response_code(400); - echo json_encode(['error' => 'area_id parameter required']); + apiError('errors.generic', ); return; } @@ -2061,7 +2052,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Check if user has access to this file area if (!$manager->canAccessFileArea((int)$areaId, $userId, $isAdmin)) { http_response_code(403); - echo json_encode(['error' => 'Access denied to this file area']); + apiError('errors.generic', ); return; } @@ -2077,7 +2068,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2095,7 +2086,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2108,7 +2099,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['file' => $file]); } else { http_response_code(404); - echo json_encode(['error' => 'File not found']); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -2175,7 +2166,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2205,7 +2196,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2245,7 +2236,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2272,7 +2263,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2284,7 +2275,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$revoked) { http_response_code(404); - echo json_encode(['error' => 'Share not found or access denied']); + apiError('errors.generic', ); return; } echo json_encode(['success' => true]); @@ -2295,7 +2286,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2358,7 +2349,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -2367,7 +2358,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!\BinktermPHP\FileAreaManager::isFeatureEnabled()) { http_response_code(404); - echo json_encode(['error' => 'File areas feature is disabled']); + apiError('errors.generic', ); return; } @@ -2387,7 +2378,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (\Exception $e) { http_response_code(403); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -2527,7 +2518,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($message); } else { http_response_code(404); - echo json_encode(['error' => 'Message not found']); + apiError('errors.generic', ); } }); @@ -2543,7 +2534,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true]); } else { http_response_code(404); - echo json_encode(['error' => 'Message not found or access denied']); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -2607,7 +2598,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($messageIds) || !is_array($messageIds)) { http_response_code(400); - echo json_encode(['error' => 'Invalid message IDs']); + apiError('errors.generic', ); return; } @@ -2658,7 +2649,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($messageIds) || !is_array($messageIds)) { http_response_code(400); - echo json_encode(['error' => 'Invalid message IDs']); + apiError('errors.generic', ); return; } @@ -2686,7 +2677,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $db->rollBack(); } http_response_code(500); - echo json_encode(['error' => 'Failed to mark messages as read']); + apiError('errors.generic', ); return; } @@ -2704,7 +2695,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($user['is_admin'])) { http_response_code(403); - echo json_encode(['error' => 'Admin privileges required']); + apiError('errors.generic', ); return; } @@ -2715,7 +2706,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($messageIds) || !is_array($messageIds)) { http_response_code(400); - echo json_encode(['error' => 'Invalid message IDs']); + apiError('errors.generic', ); return; } @@ -2871,7 +2862,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$echoareaRow || !$subscriptionManager->isUserSubscribed($userId, $echoareaRow['id'])) { http_response_code(403); - echo json_encode(['error' => 'Access denied']); + apiError('errors.generic', ); return; } } @@ -3004,7 +2995,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($message); } else { http_response_code(404); - echo json_encode(['error' => 'Message not found']); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -3126,7 +3117,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($message); } else { http_response_code(404); - echo json_encode(['error' => 'Message not found']); + apiError('errors.generic', ); } })->where(['echoarea' => '[A-Za-z0-9._@-]+', 'id' => '[0-9]+']); @@ -3140,7 +3131,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($_FILES['file'])) { http_response_code(400); - echo json_encode(['error' => 'No file uploaded']); + apiError('errors.generic', ); return; } @@ -3148,14 +3139,14 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); - echo json_encode(['error' => 'Upload error: ' . $file['error']]); + apiError('errors.generic', ); return; } $maxBytes = (int)\BinktermPHP\Config::env('NETMAIL_ATTACHMENT_MAX_SIZE', 10 * 1024 * 1024); if ($file['size'] > $maxBytes) { http_response_code(400); - echo json_encode(['error' => 'File exceeds maximum size of ' . round($maxBytes / 1048576) . ' MB']); + apiError('errors.generic', ); return; } @@ -3175,7 +3166,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!move_uploaded_file($file['tmp_name'], $destPath)) { http_response_code(500); - echo json_encode(['error' => 'Failed to store uploaded file']); + apiError('errors.generic', ); return; } @@ -3297,7 +3288,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } } else { http_response_code(400); - echo json_encode(['error' => 'Invalid message type']); + apiError('errors.generic', ); return; } @@ -3320,11 +3311,11 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $handler->flushImmediateOutboundPolls(); } else { http_response_code(500); - echo json_encode(['error' => 'Failed to send message']); + apiError('errors.generic', ); } } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3420,7 +3411,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $input = json_decode(file_get_contents('php://input'), true); if (!$input) { http_response_code(400); - echo json_encode(['error' => 'Invalid JSON input']); + apiError('errors.generic', ); return; } @@ -3430,7 +3421,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } @@ -3441,11 +3432,11 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($result); } else { http_response_code(500); - echo json_encode(['error' => $result['error'] ?? 'Failed to save draft']); + apiError('errors.generic', ); } } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3463,7 +3454,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } @@ -3472,7 +3463,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'drafts' => $drafts]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3488,7 +3479,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } @@ -3498,11 +3489,11 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'draft' => $draft]); } else { http_response_code(404); - echo json_encode(['error' => 'Draft not found']); + apiError('errors.generic', ); } } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3518,7 +3509,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } @@ -3527,7 +3518,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($result); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3547,7 +3538,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (strlen($query) < 2) { http_response_code(400); - echo json_encode(['error' => 'Query too short']); + apiError('errors.generic', ); return; } @@ -3583,13 +3574,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } if (!in_array($type, ['echomail', 'netmail'])) { http_response_code(400); - echo json_encode(['error' => 'Invalid message type']); + apiError('errors.generic', ); return; } @@ -3610,12 +3601,12 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true]); } else { http_response_code(500); - echo json_encode(['error' => 'Failed to mark message as read']); + apiError('errors.generic', ); } } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3629,13 +3620,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } if (!in_array($type, ['echomail', 'netmail'])) { http_response_code(400); - echo json_encode(['error' => 'Invalid message type']); + apiError('errors.generic', ); return; } @@ -3655,12 +3646,12 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'message' => 'Message saved']); } else { http_response_code(500); - echo json_encode(['error' => 'Failed to save message']); + apiError('errors.generic', ); } } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3674,13 +3665,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userId = $user['user_id'] ?? $user['id'] ?? null; if (!$userId) { http_response_code(500); - echo json_encode(['error' => 'User ID not found in session']); + apiError('errors.generic', ); return; } if (!in_array($type, ['echomail', 'netmail'])) { http_response_code(400); - echo json_encode(['error' => 'Invalid message type']); + apiError('errors.generic', ); return; } @@ -3703,7 +3694,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3754,7 +3745,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'real_name' => $realName]); } catch (\Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -3796,7 +3787,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($user['is_admin'])) { http_response_code(403); header('Content-Type: application/json'); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -3809,7 +3800,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userStmt->execute([$userId]); if (!$userStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } @@ -3845,7 +3836,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($user['is_admin'])) { http_response_code(403); header('Content-Type: application/json'); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -3858,7 +3849,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $userStmt->execute([$userId]); if (!$userStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } @@ -3889,7 +3880,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ]); } catch (\Exception $e) { http_response_code(500); - echo json_encode(['error' => 'Failed to fetch transactions']); + apiError('errors.generic', ); } }); @@ -3899,7 +3890,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!UserCredit::isEnabled()) { http_response_code(400); - echo json_encode(['error' => 'Credits system is disabled']); + apiError('errors.generic', ); return; } @@ -3913,14 +3904,14 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Validate amount if ($amount < 1 || $amount > 200) { http_response_code(400); - echo json_encode(['error' => 'Amount must be between 1 and 200 credits']); + apiError('errors.generic', ); return; } // Can't send to yourself if ($senderId === $recipientId) { http_response_code(400); - echo json_encode(['error' => 'Cannot send credits to yourself']); + apiError('errors.generic', ); return; } @@ -3934,7 +3925,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$recipient) { http_response_code(404); - echo json_encode(['error' => 'Recipient not found']); + apiError('errors.generic', ); return; } @@ -3942,7 +3933,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $senderBalance = UserCredit::getBalance($senderId); if ($senderBalance < $amount) { http_response_code(400); - echo json_encode(['error' => 'Insufficient credits']); + apiError('errors.generic', ); return; } @@ -3978,7 +3969,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$debitSuccess) { http_response_code(500); - echo json_encode(['error' => 'Failed to debit sender']); + apiError('errors.generic', ); return; } @@ -4002,7 +3993,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): UserCredit::TYPE_REFUND ); http_response_code(500); - echo json_encode(['error' => 'Failed to credit recipient']); + apiError('errors.generic', ); return; } @@ -4039,7 +4030,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (\Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4094,7 +4085,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true]); } else { http_response_code(404); - echo json_encode(['error' => 'Session not found']); + apiError('errors.generic', ); } }); @@ -4115,7 +4106,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true]); } else { http_response_code(500); - echo json_encode(['error' => 'Failed to logout all sessions']); + apiError('errors.generic', ); } }); @@ -4208,7 +4199,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$sessionId) { http_response_code(400); - echo json_encode(['error' => 'Session not found']); + apiError('errors.generic', ); return; } $auth = new Auth(); @@ -4265,7 +4256,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ob_clean(); http_response_code(500); header('Content-Type: application/json'); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4352,7 +4343,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ob_clean(); http_response_code(500); header('Content-Type: application/json'); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4402,7 +4393,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ob_clean(); http_response_code(500); header('Content-Type: application/json'); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4423,7 +4414,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ob_clean(); http_response_code(500); header('Content-Type: application/json'); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4524,8 +4515,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($result); } } catch (Exception $e) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => $e->getMessage()]); + apiError('errors.messages.share_create_failed', $e->getMessage(), 500, ['success' => false]); } }); @@ -4540,8 +4530,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $result = $handler->getMessageShares($id, 'echomail', $userId); echo json_encode($result); } catch (Exception $e) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => $e->getMessage()]); + apiError('errors.messages.share_lookup_failed', $e->getMessage(), 500, ['success' => false]); } }); @@ -4562,8 +4551,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($result); } } catch (Exception $e) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => $e->getMessage()]); + apiError('errors.messages.share_revoke_failed', $e->getMessage(), 500, ['success' => false]); } }); @@ -4584,8 +4572,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($result); } } catch (Exception $e) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => $e->getMessage()]); + apiError('errors.settings.exception', $e->getMessage(), 500, ['success' => false]); } }); @@ -4681,6 +4668,42 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } }); + // Client-side i18n catalog endpoint (supports lazy namespace loading) + SimpleRouter::get('/i18n/catalog', function() { + header('Content-Type: application/json'); + + $translator = new Translator(); + $resolver = new LocaleResolver($translator); + $auth = new Auth(); + $currentUser = $auth->getCurrentUser(); + + $requestedLocale = isset($_GET['locale']) ? (string)$_GET['locale'] : null; + $resolvedLocale = $resolver->resolveLocale($requestedLocale, $currentUser); + + $nsRaw = trim((string)($_GET['ns'] ?? 'common')); + $namespaces = preg_split('/\s*,\s*/', $nsRaw) ?: ['common']; + $namespaces = array_values(array_filter(array_map('trim', $namespaces), static function ($ns) { + return $ns !== ''; + })); + if (empty($namespaces)) { + $namespaces = ['common']; + } + + $catalogs = []; + foreach ($namespaces as $namespace) { + $catalogs[$namespace] = $translator->getCatalog($resolvedLocale, $namespace); + } + + $resolver->persistLocale($resolvedLocale); + + echo json_encode([ + 'success' => true, + 'locale' => $resolvedLocale, + 'default_locale' => $translator->getDefaultLocale(), + 'catalogs' => $catalogs + ]); + }); + // User settings API endpoints SimpleRouter::get('/user/settings', function() { $user = RouteHelper::requireAuth(); @@ -4692,6 +4715,11 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $handler = new MessageHandler(); $settings = $handler->getUserSettings($userId); + $translator = new Translator(); + $resolver = new LocaleResolver($translator); + $settings['locale'] = $resolver->resolveLocale((string)($settings['locale'] ?? ''), $settings); + $resolver->persistLocale($settings['locale']); + // Append shell preference from UserMeta if ($userId) { $meta = new \BinktermPHP\UserMeta(); @@ -4713,14 +4741,20 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $input = json_decode(file_get_contents('php://input'), true); if (!$input || !isset($input['settings'])) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid input']); + apiError('errors.settings.invalid_input', 'Invalid input', 400, ['success' => false]); return; } try { $settings = $input['settings']; + if (isset($settings['locale'])) { + $translator = new Translator(); + $resolver = new LocaleResolver($translator); + $settings['locale'] = $resolver->resolveLocale((string)$settings['locale']); + $resolver->persistLocale($settings['locale']); + } + // Handle shell preference separately (stored in UserMeta, not user_settings table) if (isset($settings['shell']) && $userId && !\BinktermPHP\AppearanceConfig::isShellLocked()) { $shellVal = (string)$settings['shell']; @@ -4737,8 +4771,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($result) { echo json_encode(['success' => true]); } else { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Failed to update settings']); + apiError('errors.settings.update_failed', 'Failed to update settings', 400, ['success' => false]); } } catch (Exception $e) { http_response_code(500); @@ -4752,7 +4785,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4764,7 +4797,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'users' => $users]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4773,7 +4806,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4792,14 +4825,14 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$pendingUser) { http_response_code(404); - echo json_encode(['error' => 'Pending user not found']); + apiError('errors.generic', ); return; } echo json_encode(['success' => true, 'user' => $pendingUser]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -4808,7 +4841,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4822,7 +4855,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'new_user_id' => $newUserId]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -4831,7 +4864,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4845,7 +4878,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(400); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -4854,7 +4887,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4871,7 +4904,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'users' => $result['users'], 'pagination' => $result['pagination']]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -4881,7 +4914,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4895,14 +4928,14 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$userData) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } echo json_encode(['success' => true, 'user' => $userData]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -4912,7 +4945,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -4926,7 +4959,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): $checkStmt->execute([$id]); if (!$checkStmt->fetch()) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } @@ -4938,7 +4971,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (empty($realName)) { http_response_code(400); - echo json_encode(['error' => 'Real name is required']); + apiError('errors.generic', ); return; } @@ -4955,7 +4988,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($password) { if (strlen($password) < 8) { http_response_code(400); - echo json_encode(['error' => 'Password must be at least 8 characters long']); + apiError('errors.generic', ); return; } $updateFields[] = 'password_hash = ?'; @@ -4976,7 +5009,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -4986,7 +5019,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -5002,7 +5035,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($updateStmt->rowCount() === 0) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } @@ -5010,7 +5043,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } })->where(['id' => '[0-9]+']); @@ -5020,7 +5053,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -5037,28 +5070,28 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Validate required fields if (empty($username) || empty($realName) || empty($password)) { http_response_code(400); - echo json_encode(['error' => 'Username, real name, and password are required']); + apiError('errors.generic', ); return; } // Validate username format if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) { http_response_code(400); - echo json_encode(['error' => 'Username must be 3-20 characters, letters, numbers, and underscores only']); + apiError('errors.generic', ); return; } if (\BinktermPHP\UserRestrictions::isRestrictedUsername($username) || \BinktermPHP\UserRestrictions::isRestrictedRealName($realName)) { http_response_code(400); - echo json_encode(['error' => 'This username or real name is not allowed']); + apiError('errors.generic', ); return; } // Validate password length if (strlen($password) < 8) { http_response_code(400); - echo json_encode(['error' => 'Password must be at least 8 characters long']); + apiError('errors.generic', ); return; } @@ -5070,7 +5103,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if ($checkStmt->fetch()) { http_response_code(409); - echo json_encode(['error' => 'Username already exists']); + apiError('errors.generic', ); return; } @@ -5105,7 +5138,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -5115,7 +5148,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -5127,7 +5160,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'result' => $result]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -5137,7 +5170,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -5150,7 +5183,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$targetUser) { http_response_code(404); - echo json_encode(['error' => 'User not found']); + apiError('errors.generic', ); return; } @@ -5159,7 +5192,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): // Check if user can receive reminder if (!$handler->canSendReminder($targetUser['username'])) { http_response_code(400); - echo json_encode(['error' => 'User has already logged in or is not eligible for reminders']); + apiError('errors.generic', ); return; } @@ -5174,13 +5207,13 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): ]); } else { http_response_code(400); - echo json_encode(['error' => $result['error']]); + apiError('errors.generic', ); } } catch (Exception $e) { error_log("Admin reminder error: " . $e->getMessage()); http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -5190,7 +5223,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user['is_admin']) { http_response_code(403); - echo json_encode(['error' => 'Admin access required']); + apiError('errors.generic', ); return; } @@ -5203,7 +5236,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode(['success' => true, 'users' => $usersNeedingReminder]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -5224,7 +5257,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): echo json_encode($response); } catch (Exception $e) { - echo json_encode(['error' => $e->getMessage()]); + apiError('errors.generic', ); } }); @@ -5448,7 +5481,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): if (!$user || !$user['referral_code']) { http_response_code(404); - echo json_encode(['error' => 'Referral code not found']); + apiError('errors.generic', ); return; } @@ -5487,7 +5520,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { error_log("Referral stats error: " . $e->getMessage()); http_response_code(500); - echo json_encode(['error' => 'Failed to retrieve referral statistics']); + apiError('errors.generic', ); } }); @@ -5546,7 +5579,7 @@ function sanitizeFilenameForWindows(string $name, string $fallback = 'message'): } catch (Exception $e) { error_log("Admin referral stats error: " . $e->getMessage()); http_response_code(500); - echo json_encode(['error' => 'Failed to retrieve referral statistics']); + apiError('errors.generic', ); } }); }); diff --git a/src/Auth.php b/src/Auth.php index eaad8a92..bc742397 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -158,7 +158,10 @@ public function requireAuth() if (!$user) { header('HTTP/1.1 401 Unauthorized'); header('Content-Type: application/json'); - echo json_encode(['error' => 'Authentication required']); + echo json_encode([ + 'error_code' => 'errors.auth.authentication_required', + 'error' => 'Authentication required' + ]); exit; } @@ -175,7 +178,10 @@ public function requireAuth() if ($expected === null || $token === '' || !hash_equals($expected, $token)) { http_response_code(403); header('Content-Type: application/json'); - echo json_encode(['error' => 'Invalid CSRF token']); + echo json_encode([ + 'error_code' => 'errors.auth.invalid_csrf_token', + 'error' => 'Invalid CSRF token' + ]); exit; } } diff --git a/src/I18n/LocaleResolver.php b/src/I18n/LocaleResolver.php new file mode 100644 index 00000000..1d7dea53 --- /dev/null +++ b/src/I18n/LocaleResolver.php @@ -0,0 +1,149 @@ +translator = $translator; + } + + /** + * Resolve locale using priority: + * 1) Explicit locale argument + * 2) Authenticated user settings locale (if provided in $user['locale']) + * 3) Locale cookie + * 4) Accept-Language header best match + * 5) Default locale + */ + public function resolveLocale(?string $requestedLocale = null, ?array $user = null): string + { + $candidates = []; + + if (is_string($requestedLocale) && trim($requestedLocale) !== '') { + $candidates[] = $requestedLocale; + } + + if (is_array($user) && isset($user['locale'])) { + $candidates[] = (string)$user['locale']; + } + + if (isset($_COOKIE[self::COOKIE_NAME])) { + $candidates[] = (string)$_COOKIE[self::COOKIE_NAME]; + } + + $acceptLanguage = (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''); + if ($acceptLanguage !== '') { + $candidates = array_merge($candidates, $this->parseAcceptLanguage($acceptLanguage)); + } + + foreach ($candidates as $candidate) { + $normalized = $this->normalizeLocale($candidate); + if ($normalized !== '' && $this->translator->isSupportedLocale($normalized)) { + return $normalized; + } + + $base = $this->baseLanguage($normalized); + if ($base !== '' && $this->translator->isSupportedLocale($base)) { + return $base; + } + } + + return $this->translator->getDefaultLocale(); + } + + public function persistLocale(string $locale): void + { + $normalized = $this->normalizeLocale($locale); + if ($normalized === '') { + return; + } + + $resolved = $this->resolveLocale($normalized); + + if (headers_sent()) { + return; + } + + setcookie(self::COOKIE_NAME, $resolved, [ + 'expires' => time() + (86400 * 365), + 'path' => '/', + 'httponly' => false, + 'samesite' => 'Lax', + ]); + } + + /** + * @return string[] + */ + private function parseAcceptLanguage(string $header): array + { + $parts = explode(',', $header); + $weighted = []; + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + $segments = explode(';', $part); + $locale = trim($segments[0]); + if ($locale === '' || $locale === '*') { + continue; + } + + $q = 1.0; + if (isset($segments[1]) && preg_match('/q=([0-9.]+)/', $segments[1], $m)) { + $q = (float)$m[1]; + } + + $weighted[] = ['locale' => $locale, 'q' => $q]; + } + + usort($weighted, static function (array $a, array $b): int { + if ($a['q'] === $b['q']) { + return 0; + } + return ($a['q'] > $b['q']) ? -1 : 1; + }); + + $locales = []; + foreach ($weighted as $item) { + $locales[] = (string)$item['locale']; + } + + return $locales; + } + + private function normalizeLocale(string $locale): string + { + $clean = str_replace('_', '-', trim($locale)); + if ($clean === '') { + return ''; + } + + $parts = explode('-', $clean); + $parts[0] = strtolower($parts[0]); + if (isset($parts[1])) { + $parts[1] = strtoupper($parts[1]); + } + + return implode('-', $parts); + } + + private function baseLanguage(string $locale): string + { + if ($locale === '') { + return ''; + } + $parts = explode('-', $locale); + return strtolower($parts[0] ?? ''); + } +} + diff --git a/src/I18n/Translator.php b/src/I18n/Translator.php new file mode 100644 index 00000000..e4934fe8 --- /dev/null +++ b/src/I18n/Translator.php @@ -0,0 +1,234 @@ + */ + private array $supportedLocales = []; + /** @var array>> */ + private array $catalogCache = []; + + public function __construct(?string $basePath = null, ?string $defaultLocale = null, ?array $supportedLocales = null) + { + $this->basePath = $basePath ?? (__DIR__ . '/../../config/i18n'); + $this->defaultLocale = $this->normalizeLocale($defaultLocale ?? (string)Config::env('I18N_DEFAULT_LOCALE', 'en')); + + if (is_array($supportedLocales) && !empty($supportedLocales)) { + foreach ($supportedLocales as $locale) { + $normalized = $this->normalizeLocale((string)$locale); + if ($normalized !== '') { + $this->supportedLocales[$normalized] = true; + } + } + } else { + $configured = (string)Config::env('I18N_SUPPORTED_LOCALES', ''); + if ($configured !== '') { + $parts = preg_split('/\s*,\s*/', $configured) ?: []; + foreach ($parts as $part) { + $normalized = $this->normalizeLocale($part); + if ($normalized !== '') { + $this->supportedLocales[$normalized] = true; + } + } + } + + // Auto-discover locales from filesystem when env config is absent. + if (empty($this->supportedLocales) && is_dir($this->basePath)) { + $dirs = glob($this->basePath . '/*', GLOB_ONLYDIR) ?: []; + foreach ($dirs as $dir) { + $name = basename($dir); + $normalized = $this->normalizeLocale($name); + if ($normalized !== '') { + $this->supportedLocales[$normalized] = true; + } + } + } + } + + // Always include default locale even if not explicitly listed. + $this->supportedLocales[$this->defaultLocale] = true; + } + + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + public function isSupportedLocale(string $locale): bool + { + $normalized = $this->normalizeLocale($locale); + return isset($this->supportedLocales[$normalized]); + } + + /** + * @return string[] + */ + public function getSupportedLocales(): array + { + $locales = array_keys($this->supportedLocales); + sort($locales, SORT_STRING); + return $locales; + } + + public function translate(string $key, array $params = [], ?string $locale = null, array $namespaces = ['common']): string + { + $resolvedLocale = $this->resolveToSupportedLocale($locale); + $namespaces = $this->normalizeNamespaces($namespaces); + + $value = $this->lookupInNamespaces($key, $resolvedLocale, $namespaces); + if ($value === null && $resolvedLocale !== $this->defaultLocale) { + $value = $this->lookupInNamespaces($key, $this->defaultLocale, $namespaces); + } + if ($value === null) { + $value = $key; + } + + return $this->interpolate($value, $params); + } + + /** + * Returns a merged catalog for a locale/namespace with fallback from default locale. + * + * @return array + */ + public function getCatalog(string $locale, string $namespace = 'common'): array + { + $resolvedLocale = $this->resolveToSupportedLocale($locale); + $namespace = trim($namespace); + if ($namespace === '') { + $namespace = 'common'; + } + + $defaultCatalog = $this->loadCatalog($this->defaultLocale, $namespace); + $localeCatalog = $resolvedLocale === $this->defaultLocale + ? [] + : $this->loadCatalog($resolvedLocale, $namespace); + + /** @var array $merged */ + $merged = array_merge($defaultCatalog, $localeCatalog); + return $merged; + } + + private function resolveToSupportedLocale(?string $locale): string + { + $normalized = $this->normalizeLocale((string)$locale); + if ($normalized !== '' && isset($this->supportedLocales[$normalized])) { + return $normalized; + } + + $base = $this->getBaseLanguage($normalized); + if ($base !== '' && isset($this->supportedLocales[$base])) { + return $base; + } + + return $this->defaultLocale; + } + + /** + * @param string[] $namespaces + * @return string[] + */ + private function normalizeNamespaces(array $namespaces): array + { + $normalized = []; + foreach ($namespaces as $namespace) { + $ns = trim((string)$namespace); + if ($ns !== '') { + $normalized[] = $ns; + } + } + + if (empty($normalized)) { + $normalized[] = 'common'; + } + + return array_values(array_unique($normalized)); + } + + private function lookupInNamespaces(string $key, string $locale, array $namespaces): ?string + { + foreach ($namespaces as $namespace) { + $catalog = $this->loadCatalog($locale, $namespace); + if (isset($catalog[$key])) { + return (string)$catalog[$key]; + } + } + return null; + } + + /** + * @return array + */ + private function loadCatalog(string $locale, string $namespace): array + { + if (isset($this->catalogCache[$locale][$namespace])) { + return $this->catalogCache[$locale][$namespace]; + } + + $path = $this->basePath . '/' . $locale . '/' . $namespace . '.php'; + if (!is_file($path)) { + $this->catalogCache[$locale][$namespace] = []; + return []; + } + + $data = include $path; + if (!is_array($data)) { + $this->catalogCache[$locale][$namespace] = []; + return []; + } + + $catalog = []; + foreach ($data as $k => $v) { + if (is_string($k) && is_string($v)) { + $catalog[$k] = $v; + } + } + + $this->catalogCache[$locale][$namespace] = $catalog; + return $catalog; + } + + private function interpolate(string $value, array $params): string + { + if (empty($params)) { + return $value; + } + + foreach ($params as $key => $paramValue) { + $value = str_replace('{' . $key . '}', (string)$paramValue, $value); + } + + return $value; + } + + private function normalizeLocale(string $locale): string + { + $clean = str_replace('_', '-', trim($locale)); + if ($clean === '') { + return ''; + } + + $parts = explode('-', $clean); + $parts[0] = strtolower($parts[0]); + if (isset($parts[1])) { + $parts[1] = strtoupper($parts[1]); + } + + return implode('-', $parts); + } + + private function getBaseLanguage(string $locale): string + { + if ($locale === '') { + return ''; + } + $parts = explode('-', $locale); + return strtolower($parts[0] ?? ''); + } +} + diff --git a/src/MessageHandler.php b/src/MessageHandler.php index dd456e82..ad4b33a0 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -1822,8 +1822,8 @@ public function getUserSettings($userId) if (!$settings) { // Create default settings for user if they don't exist $insertStmt = $this->db->prepare(" - INSERT INTO user_settings (user_id, messages_per_page, threaded_view, netmail_threaded_view, default_sort, font_family, font_size, date_format, default_tagline) - VALUES (?, 25, FALSE, FALSE, 'date_desc', 'Courier New, Monaco, Consolas, monospace', 16, 'en-US', NULL) + INSERT INTO user_settings (user_id, messages_per_page, threaded_view, netmail_threaded_view, default_sort, font_family, font_size, date_format, locale, default_tagline) + VALUES (?, 25, FALSE, FALSE, 'date_desc', 'Courier New, Monaco, Consolas, monospace', 16, 'en-US', 'en', NULL) ON CONFLICT (user_id) DO UPDATE SET messages_per_page = COALESCE(user_settings.messages_per_page, 25), threaded_view = COALESCE(user_settings.threaded_view, FALSE), @@ -1832,6 +1832,7 @@ public function getUserSettings($userId) font_family = COALESCE(user_settings.font_family, 'Courier New, Monaco, Consolas, monospace'), font_size = COALESCE(user_settings.font_size, 16), date_format = COALESCE(user_settings.date_format, 'en-US'), + locale = COALESCE(user_settings.locale, 'en'), default_tagline = COALESCE(user_settings.default_tagline, NULL) "); $insertStmt->execute([$userId]); @@ -1844,11 +1845,16 @@ public function getUserSettings($userId) 'font_family' => 'Courier New, Monaco, Consolas, monospace', 'font_size' => 16, 'date_format' => 'en-US', + 'locale' => 'en', 'signature_text' => '', 'default_tagline' => '' ]; } + if (empty($settings['locale'])) { + $settings['locale'] = 'en'; + } + return $settings; } @@ -1876,6 +1882,7 @@ public function updateUserSettings($userId, $settings) 'auto_refresh' => 'BOOLEAN', 'quote_coloring' => 'BOOLEAN', 'date_format' => 'STRING', + 'locale' => 'LOCALE', 'signature_text' => 'SIGNATURE', 'default_tagline' => 'TAGLINE' ]; @@ -1922,6 +1929,13 @@ public function updateUserSettings($userId, $settings) } $params[] = in_array($tagline, $taglines, true) ? $tagline : null; break; + case 'LOCALE': + $locale = str_replace('_', '-', trim((string)$value)); + if (!preg_match('/^[a-z]{2,3}(?:-[A-Z]{2})?$/', $locale)) { + $locale = 'en'; + } + $params[] = $locale; + break; default: $params[] = $value; break; diff --git a/src/Template.php b/src/Template.php index d47ca940..b0125bcb 100644 --- a/src/Template.php +++ b/src/Template.php @@ -19,6 +19,8 @@ use BinktermPHP\Binkp\Config\BinkpConfig; use BinktermPHP\AppearanceConfig; use BinktermPHP\BbsConfig; +use BinktermPHP\I18n\LocaleResolver; +use BinktermPHP\I18n\Translator; use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\TwigFunction; @@ -27,11 +29,15 @@ class Template { private $twig; private $auth; + private Translator $translator; + private LocaleResolver $localeResolver; private string $activeShell = 'web'; public function __construct() { $this->auth = new Auth(); + $this->translator = new Translator(); + $this->localeResolver = new LocaleResolver($this->translator); $currentUser = $this->auth->getCurrentUser(); $this->activeShell = $this->resolveActiveShell($currentUser); @@ -86,6 +92,21 @@ private function addGlobalVariables(?array $currentUser = null) $currentUser = $this->auth->getCurrentUser(); } + $currentUserId = (int)($currentUser['user_id'] ?? $currentUser['id'] ?? 0); + $userSettings = null; + if ($currentUserId > 0) { + try { + $handler = new MessageHandler(); + $userSettings = $handler->getUserSettings($currentUserId); + } catch (\Throwable $e) { + $userSettings = null; + } + } + + $preferredLocale = is_array($userSettings) ? (string)($userSettings['locale'] ?? '') : ''; + $locale = $this->localeResolver->resolveLocale($preferredLocale !== '' ? $preferredLocale : null, $currentUser); + $this->localeResolver->persistLocale($locale); + // Get dynamic system info from BinkP config try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); @@ -109,6 +130,10 @@ private function addGlobalVariables(?array $currentUser = null) $this->twig->addGlobal("favicon_svg", $favicon_svg); $this->twig->addGlobal("favicon_ico", $favicon_ico); $this->twig->addGlobal("favicon_png", $favicon_png); + $this->twig->addGlobal('locale', $locale); + $this->twig->addGlobal('default_locale', $this->translator->getDefaultLocale()); + $this->twig->addGlobal('supported_locales', $this->translator->getSupportedLocales()); + $this->twig->addGlobal('i18n_namespaces', ['common', 'errors']); // CSRF token — stored per-user in UserMeta so it is shared across web // sessions and the telnet daemon. Generated lazily for users who were @@ -219,10 +244,9 @@ private function addGlobalVariables(?array $currentUser = null) $systemDefaultEchoInterface = $bbsConfig['default_echo_interface'] ?? 'echolist'; $defaultEchoList = $systemDefaultEchoInterface; - if ($currentUser && !empty($currentUser['user_id'])) { + if ($currentUserId > 0) { try { - $handler = new MessageHandler(); - $settings = $handler->getUserSettings($currentUser['user_id']); + $settings = is_array($userSettings) ? $userSettings : []; if (!empty($settings['theme']) && !AppearanceConfig::isThemeLocked()) { // Validate that the user's theme is in the available themes if (in_array($settings['theme'], $availableThemes, true)) { @@ -249,6 +273,13 @@ private function addGlobalVariables(?array $currentUser = null) $this->twig->addFunction(new TwigFunction('bbs_feature_enabled', function(string $feature): bool { return BbsConfig::isFeatureEnabled($feature); })); + $this->twig->addFunction(new TwigFunction('t', function(string $key, array $params = [], ?string $namespace = 'common') use ($locale): string { + $namespaces = ['common']; + if (is_string($namespace) && $namespace !== '' && $namespace !== 'common') { + array_unshift($namespaces, $namespace); + } + return $this->translator->translate($key, $params, $locale, $namespaces); + })); } /** diff --git a/templates/base.twig b/templates/base.twig index 3db2fd60..67fe09d9 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -1,5 +1,5 @@ - + @@ -69,6 +69,11 @@ + {% if current_user and bbs_feature_enabled('chat') %} diff --git a/templates/old.base.twig b/templates/old.base.twig index 5de6ce32..c71cd8eb 100644 --- a/templates/old.base.twig +++ b/templates/old.base.twig @@ -1,5 +1,5 @@ - + @@ -69,6 +69,11 @@ + {% if current_user and bbs_feature_enabled('chat') %} diff --git a/templates/settings.twig b/templates/settings.twig index 43d19855..8f4fb053 100644 --- a/templates/settings.twig +++ b/templates/settings.twig @@ -45,6 +45,16 @@
Your local timezone for displaying dates
+
+ + +
Preferred language for the interface
+
+
@@ -1456,10 +1457,29 @@ function showAddAddressModal() { $('#addressBookModalTitle').text('Add Address Book Entry'); $('#addressBookEntryId').val(''); $('#addressBookForm')[0].reset(); + clearAddressBookModalError(); $('#addressBookModal').modal('show'); } +function showAddressBookModalError(message) { + const fallback = uiT('errors.address_book.create_failed', 'Failed to save entry'); + const text = (typeof message === 'string' && message.trim() !== '') ? message : fallback; + const errorEl = $('#addressBookModalError'); + if (errorEl.length) { + errorEl.text(text).removeClass('d-none'); + } + showError(text); +} + +function clearAddressBookModalError() { + const errorEl = $('#addressBookModalError'); + if (errorEl.length) { + errorEl.addClass('d-none').text(''); + } +} + function saveComposeAddressBookEntry() { + clearAddressBookModalError(); const data = { name: $('#addressBookName').val().trim(), messaging_user_id: $('#addressBookUserId').val().trim(), @@ -1471,7 +1491,7 @@ function saveComposeAddressBookEntry() { // Basic validation if (!data.name || !data.messaging_user_id || !data.node_address) { - showError(uiT('errors.address_book.required_fields', 'Name, user ID, and node address are required')); + showAddressBookModalError(uiT('errors.address_book.required_fields', 'Name, user ID, and node address are required')); return; } @@ -1494,14 +1514,14 @@ function saveComposeAddressBookEntry() { const error = window.getApiErrorMessage ? window.getApiErrorMessage(response, uiT('errors.address_book.create_failed', 'Failed to save entry')) : uiT('errors.address_book.create_failed', 'Failed to save entry'); - showError(error); + showAddressBookModalError(error); } }, error: function(xhr) { const error = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.address_book.create_failed', 'Failed to save entry')) : uiT('errors.address_book.create_failed', 'Failed to save entry'); - showError(error); + showAddressBookModalError(error); } }); } diff --git a/templates/netmail.twig b/templates/netmail.twig index 1c3a327d..56e95e4e 100644 --- a/templates/netmail.twig +++ b/templates/netmail.twig @@ -192,6 +192,7 @@