Compare commits

...

10 Commits

Author SHA1 Message Date
Ty Clifford 36c488ca1e - Implemented.
Rebuilt embed-example.html with inline, full-screen, and popup examples.
Fixed host-page/frame scrolling and contained reply navigation.
New logins now transactionally replace prior sessions; displaced clients 
return to login.
Added per-user session/IP locking with automatic SQLite migration.
Added modular Spam Control dashboard for locks, honeypot, and CAPTCHA 
settings.
Moved CAPTCHA controls out of Plans & Platform.
2026-06-09 18:54:25 -04:00
Ty Clifford 5ca55bf7c1 - Playback: Fixed future Chrome recordings in cyberchat-app.js (line 865):
Records one finalized WebM instead of 250ms fragments.
Waits for final audio data before releasing the microphone.
Handles startup/finalization failures cleanly.
Bumped frontend cache version to .8.
Verified JavaScript syntax, JSON, browser rendering, and no console 
errors. Existing uploaded clips remain unchanged. A real microphone test 
wasn’t available in the browser runner.
References: MDN MediaRecorder, Chromium WebM metadata issue.
2026-06-09 11:19:28 -04:00
Ty Clifford eb57cde99f - Implemented across bootstrap.php, chat frontend, and dashboard:
Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps.
Configurable text/voice reply threads and depth.
Registration/login-only honeypot and internal CAPTCHA.
Optional Google reCAPTCHA v2/v3 with server verification.
Archive/export support for replies and bitrate.
Dashboard controls for all settings.
Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and 
browser console. Native PHP lint was unavailable because PHP is not 
installed.
Google implementation follows Siteverify and reCAPTCHA v3.
2026-06-09 10:59:58 -04:00
Ty Clifford 88805057cc - Changelog, Readme, version 2026-06-09 05:29:15 -04:00
Ty Clifford 5c1d236322 - Updated README 2026-06-09 05:01:30 -04:00
Ty Clifford 6217a216a7 - Implemented.
Safari/iPhone recording now uses MP4/AAC when WebM is unavailable.
WebM remains the default when supported and FFmpeg is disabled.
Added optional FFmpeg profiles: recommended M4A/AAC-LC or requested 
MP4/H.264 + AAC.
Added MIME-aware, playsinline audio playback.
Added dashboard controls under Plans & Platform → Voice Transcoding / 
FFmpeg.
Added Apache/Nginx media MIME configuration and documentation.
Core implementation: voice_transcoder.php (line 72), messages.php (line 
86), and cyberchat-app.js (line 635).
2026-06-09 02:21:42 -04:00
Ty Clifford ae0fb45c48 - Fixed the actual race:
Sent messages wait for polling to catch up before rendering.
Missing interleaved messages cannot appear above later ones.
Messages sort by server timestamp, then ID.
Timestamps are assigned atomically during SQLite insertion.
Client updated to 20260609.5.
2026-06-09 01:35:19 -04:00
Ty Clifford 195f3fa56d - Fixed the polling/display system:
One ID-keyed message ledger now handles sends and polls.
Messages are always sorted by server ID, latest at bottom.
Duplicate DOM rows and voice players are removed.
Poll cursors advance only from validated received messages.
Voice autoplay queues each received clip once.
Added exact user_id ownership tracking.
Cache-busted client to 20260609.4.
2026-06-09 01:01:22 -04:00
Ty Clifford a1971f9515 -
Groups is fully removed:

No homepage navigation, Account feature, tier setting, API, or client 
code.
Legacy group tables, entitlements, messages, voice files, and config are 
purged automatically.
New cache-busted client: cyberchat-app.js?v=20260609.3.
Browser and SQLite migration tests passed with no console errors.
2026-06-09 00:16:32 -04:00
Ty Clifford ccdbc47642 - Implemented.
Replaced polling with a single queued cursor worker, timeout recovery, 
backoff, deduplication, chronological insertion, and capped DOM history 
in cyberchat-v4.js.
Removed Groups UI, API, configuration, tier features, admin controls, 
and archive exposure.
Legacy group records remain stored but inaccessible.
Added configurable polling limits in config.json.
2026-06-08 23:28:42 -04:00
20 changed files with 2601 additions and 1897 deletions
+18
View File
@@ -11,6 +11,24 @@
</IfModule> </IfModule>
</FilesMatch> </FilesMatch>
# Always revalidate HTML so new asset versions take effect immediately.
<IfModule mod_headers.c>
<FilesMatch "\.(html?)$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
# Voice playback MIME types, including Safari/iPhone-compatible AAC in MP4.
<IfModule mod_mime.c>
AddType audio/webm .webm
AddType audio/wav .wav
AddType audio/aac .aac
AddType audio/mp4 .m4a
AddType video/mp4 .mp4
</IfModule>
# Deny direct access to storage and internal PHP libraries # Deny direct access to storage and internal PHP libraries
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
+158
View File
@@ -0,0 +1,158 @@
# Changelog
All notable changes to CyberChat are documented here.
CyberChat `2.1.0` is the current baseline release. The earlier `2.0.0` commit is retained as the initial repository release. Commit hashes are included for traceability.
## [Unreleased]
### Added
- Added full-screen overlay and popup window examples to `embed-example.html`.
- Added a Spam Control dashboard for session pinning, IP binding, honeypot, internal CAPTCHA, and Google reCAPTCHA settings.
- Added per-user account locks tied to an administrator-selected session token and IP.
### Changed
- Successful logins now silently replace all older sessions for the same account.
- Moved registration/login CAPTCHA controls out of Plans & Platform and into Spam Control.
- Scoped full-page layout rules to `index.html` so embedded chat CSS does not alter the host page.
### Fixed
- Kept message and reply navigation scrolling inside the chat frame instead of moving the surrounding page.
## [2.1.0] - 2026-06-09 - Current Baseline Release
This release establishes the current product baseline. It includes the account, subscription, archive, administration, polling, mobile audio, and FFmpeg work developed after `2.0.0`.
### Added
- Added a complete project changelog and expanded feature documentation.
- Added optional-email registration; users can create free accounts without supplying an email address. (`063b8dc`)
- Added Mailgun email verification using reusable cURL mail delivery, verification links, and six-digit codes. (`063b8dc`)
- Added tier-controlled email two-factor authentication and expiring login challenges. (`063b8dc`)
- Added Stripe Checkout, Billing Portal access, signed webhook handling, subscription synchronization, and end-of-period cancellation. (`063b8dc`)
- Added verified-email enforcement before premium checkout. (`063b8dc`)
- Added configurable Free, Plus, and Pro plans with prices, currencies, billing intervals, Stripe Price IDs, active status, ordering, and feature entitlements. (`063b8dc`)
- Added a global subscription visibility switch that hides new sales while preserving management for existing subscribers. (`063b8dc`)
- Added administrator subscriber tracking and subscription status data. (`063b8dc`)
- Added personal authenticated archives with tier-based retention, search, JSON export, CSV export, voice archive access, and voice export access. (`063b8dc`)
- Added configurable recording-presence sending and viewing. (`063b8dc`)
- Added configurable automatic voice sending. (`063b8dc`)
- Added tier-configurable automatic voice playback with a saved browser preference. (`d0e4a87`)
- Added tier-based custom username colors. (`063b8dc`)
- Added administrator tier overrides that grant plan features without changing Stripe billing. (`d0e4a87`)
- Added visitor and feature event statistics using salted IP hashes. (`063b8dc`)
- Added optional automatic and manual SQLite-to-MySQL/MariaDB mirroring. (`063b8dc`)
- Added the **Plans & Platform** administrator module for plans, services, subscribers, statistics, and mirroring. (`063b8dc`)
- Added request timeouts, configurable retry backoff, and a capped rendered-message buffer. (`ef7a9a3`, `ccdbc47`)
- Added asset cache-busting to deliver frontend fixes immediately. (`d0e4a87`)
- Added Safari and iPhone recording support using MP4/AAC when WebM is unavailable. WebM/Opus remains the preferred browser format where supported. (`6217a21`)
- Added upload handling and MIME detection for MP4/M4A and AAC alongside WebM and WAV. (`6217a21`)
- Added MIME-aware, inline mobile audio playback through `<source>` elements. (`6217a21`)
- Added optional FFmpeg voice transcoding with dashboard configuration for:
- Enable or disable
- Executable name or absolute path
- AAC bitrate
- Conversion timeout
- Original-file fallback
- M4A/AAC-LC output
- MP4/H.264 baseline plus AAC-LC output
- Added `lib/voice_transcoder.php` with process timeout handling, output validation, atomic source replacement, and failure fallback. (`6217a21`)
- Added Apache and Nginx MIME mappings for WebM, WAV, AAC, M4A, and MP4 voice files. (`6217a21`)
### Changed
- Raised the documented PHP requirement from 8.0 to 8.1 for the expanded platform. (`063b8dc`)
- Redirected the public archive browser to the authenticated personal archive experience. (`063b8dc`)
- Expanded the SQLite schema for plans, plan features, subscriptions, email tokens, login challenges, recording presence, visitor events, and Stripe event deduplication. (`063b8dc`)
- Reworked the browser application into `cyberchat-v4.js` for account, billing, archive, plan, and voice workflows. (`063b8dc`)
- Replaced overlapping polling with a single queued cursor worker. (`ccdbc47`)
- Added immediate catch-up polling, stalled-request cancellation, bounded exponential retry, deduplication, chronological insertion, and capped browser history. (`ccdbc47`)
- Prevented local sends from advancing the server cursor and skipping interleaved messages. (`ef7a9a3`)
- Made successful sends request immediate reconciliation while allowing polling to continue after failures. (`ef7a9a3`)
- Disabled browser caching for API requests. (`ef7a9a3`)
- Renamed the active browser client from `cyberchat-v4.js` to `cyberchat-app.js` and removed the obsolete original `cyberchat.js`. (`a1971f9`)
- Added exact `user_id` ownership data to message payloads so sent and received messages can be identified reliably. (`195f3fa`)
- Changed message ordering to use server timestamps followed by message ID. (`ae0fb45`)
- Changed message timestamps to be assigned atomically during SQLite insertion. (`ae0fb45`)
- Expanded `README.md` into a full feature, audio, configuration, deployment, administration, billing, archive, and diagnostics reference. (`5c1d236`)
- Advanced frontend cache-busting versions through `20260609.3`, `.4`, `.5`, and `.6` as client behavior changed. (`a1971f9`, `195f3fa`, `ae0fb45`, `6217a21`)
### Fixed
- Fixed incoming voice clips failing to populate on another user's screen. (`d0e4a87`)
- Fixed polling, unread state, and live membership refresh after group and voice activity. (`d0e4a87`)
- Fixed polling stopping after voice or text sends. (`ef7a9a3`)
- Fixed stalled polls by aborting and retrying them automatically. (`ef7a9a3`)
- Fixed message loss when a local send occurred between server poll results. (`ef7a9a3`)
- Replaced separate send and poll rendering paths with one ID-keyed message ledger. (`195f3fa`)
- Removed duplicate message rows and duplicate voice players during DOM reconciliation. (`195f3fa`)
- Ensured poll cursors advance only from validated server messages. (`195f3fa`)
- Ensured each newly received voice clip is queued for automatic playback only once. (`195f3fa`)
- Fixed the remaining send/poll race by waiting for polling to catch up before rendering acknowledged sent messages. (`ae0fb45`)
- Prevented interleaved messages from appearing above or below the wrong message after incremental refresh. (`ae0fb45`)
- Kept the latest message at the bottom without requiring a full-page refresh. (`195f3fa`, `ae0fb45`)
### Removed
- Fully removed groups from homepage navigation, account features, tier settings, APIs, client code, archives, and administrator controls. (`a1971f9`)
- Added automatic cleanup of legacy group tables, entitlements, messages, voice files, and configuration. (`a1971f9`)
### Historical Group Development
Groups were developed during the `2.1.0` cycle, then removed before the baseline was finalized. These entries are retained only to preserve project history.
- Added private groups with plan-based member limits as part of the platform expansion. (`063b8dc`)
- Added a global **Group Availability** switch that hid navigation and blocked group APIs without initially deleting stored group data. (`2881a3b`)
- Added missing global and per-tier group controls to the administrator dashboard. (`de8ec43`)
- Split tier group configuration into an explicit enable toggle and a separate maximum-members value with a minimum of two, including the owner. (`7214bb6`)
- Removed group UI, APIs, configuration, tier entitlements, administrator controls, and archive exposure during the polling redesign. Legacy records were initially left inaccessible. (`ccdbc47`)
- See **Removed** above for the final schema and data purge. (`a1971f9`)
## [2.0.0] - 2026-06-08 - Initial Repository Release
### Added
- Added the self-contained PHP, JavaScript, HTML5, and SQLite chat application. (`33a176f`)
- Added instant callsign and passphrase registration with bcrypt password hashing. (`33a176f`)
- Added session login, secure random tokens, IP/cookie restrictions, expiration, and logout. (`33a176f`)
- Added text chat with configurable length limits and regular HTTP polling. (`33a176f`)
- Added daily chat rollover and per-day SQLite archives under `archive/{year}/{month}/{day}.sqlite`. (`33a176f`)
- Added browser voice recording, preview, upload, storage, and queued WAV/WebM playback. (`33a176f`)
- Added the dark neon cyberpunk interface and saved light-mode toggle. (`33a176f`)
- Added an embeddable frontend initialized with `CyberChat.init()`. (`33a176f`)
- Added the administrator dashboard with:
- System overview
- Live message search, editing, bulk deletion, and day cleanup
- Voice clip playback, filtering, archiving, and deletion
- Archive browsing and moderation
- User creation and account management
- Session inspection and termination
- Live JSON configuration editing
- Added runtime diagnostics for PHP, SQLite, storage permissions, and sessions. (`33a176f`)
- Added Apache upload protection and Nginx deployment configuration. (`33a176f`, `2e5c42c`)
- Corrected the README product title to `CYBERCHAT v2.0`. (`2e5c42c`, `9e3ff61`)
## Commit Index
| Date | Commit | Summary |
|---|---|---|
| 2026-06-08 | `33a176f` | Initial `2.0.0` application |
| 2026-06-08 | `2e5c42c` | Apache rules and initial README title correction |
| 2026-06-08 | `9e3ff61` | Finalized the `CYBERCHAT v2.0` README title |
| 2026-06-08 | `063b8dc` | Major voice, account, plans, Mailgun, Stripe, archive, statistics, and MySQL expansion |
| 2026-06-08 | `d0e4a87` | Automatic voice play, admin tier overrides, and live refresh fixes |
| 2026-06-08 | `2881a3b` | Global group availability |
| 2026-06-08 | `de8ec43` | Restored missing global and tier group controls |
| 2026-06-08 | `7214bb6` | Explicit group enable toggle and member maximum |
| 2026-06-08 | `ef7a9a3` | Poll recovery after voice and text sends |
| 2026-06-08 | `ccdbc47` | Queued polling redesign and initial group removal |
| 2026-06-09 | `a1971f9` | Complete group removal and legacy cleanup |
| 2026-06-09 | `195f3fa` | ID-keyed message ledger and duplicate-player cleanup |
| 2026-06-09 | `ae0fb45` | Final chronological ordering race fix |
| 2026-06-09 | `6217a21` | Safari/iPhone audio and optional FFmpeg transcoding |
| 2026-06-09 | `5c1d236` | Comprehensive README documentation |
All development after `2.0.0`, together with the changelog and version alignment, comprises the `2.1.0` baseline release.
+354 -59
View File
@@ -1,35 +1,166 @@
# CyberChat # CyberChat 2.2.0
CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with configurable paid tiers, Stripe subscriptions, Mailgun email verification, private groups, voice messages, personal archives, an administrator dashboard, and an optional MySQL/MariaDB mirror. CyberChat 2.2.0 is the current baseline release. It is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with text and voice messaging, configurable service tiers, Stripe subscriptions, Mailgun email verification, private archives, resilient queued polling, an administrator dashboard, and optional FFmpeg and MySQL/MariaDB integrations.
## Included Features ## Current Capabilities
- Registration without an email address ### Chat and Interface
- Verified email required before premium checkout
- Mailgun email verification and email two-factor login codes - Authenticated public chat with text and voice messages
- Stripe Checkout, Billing Portal, webhook synchronization, and cancellation - Reply-to controls for text and voice with configurable thread depth
- Configurable plans, prices, Stripe Price IDs, limits, and feature assignments - Chronological message reconciliation with the newest message at the bottom
- Subscription display switch that hides new sales while preserving billing management for existing customers - Single-worker cursor polling with catch-up batches, deduplication, request timeouts, retry backoff, and immediate wake-up after sending
- Globally configurable private groups with plan-based member limits, including the owner - Poll recovery when the browser returns online or the tab becomes visible
- Premium-configurable recording presence for both sending and viewing - Configurable poll interval, timeout, maximum retry delay, batch size, and browser message limit
- Premium-configurable automatic voice sending - Online-user count and connection-status indicator
- Tier-configurable automatic playback of newly received voice clips - Configurable message and username length limits
- Administrator tier overrides that grant plan features without payment - Automatic link detection for HTTP and HTTPS URLs
- Resilient polling with stalled-request timeouts and post-send reconciliation - Dark cyberpunk interface with neon green, blue, purple, pink, red, and orange accents
- Plan-based custom username colors - Saved light-mode toggle
- Personal text archives retained for at least 30 days - Configurable application title, subtitle, and username color palette
- Plan-based voice archive access and JSON/CSV exports - Embeddable frontend through `CyberChat.init()`
- Visitor and feature usage statistics
- SQLite by default, with optional automatic MySQL/MariaDB mirroring ### Voice and Audio
- Dark neon interface with a saved light-mode toggle
- Browser microphone recording with a configurable duration and upload-size limit
- Per-tier recording and FFmpeg output bitrate from 48 to 320 kbps
- WebM/Opus recording by default on supported browsers
- MP4/AAC and AAC recording fallback for Safari and iPhone
- Upload validation for WebM, WAV, MP4/M4A, and AAC
- Recording preview with explicit send and discard controls
- Configurable automatic voice sending by tier
- Configurable recording-status indication by tier; entitled users can both send and see the `is recording...` status
- Configurable sequential playback of newly received voice clips by tier
- Saved per-browser automatic-play preference
- Automatic-play queue recovery and a user notice when browser autoplay policy blocks playback
- Custom cyberpunk audio player for chat and archives
- Audio play/pause, seek bar, elapsed and total time, mute control, playback animation, and single-active-player behavior
- MIME-aware `<source>` elements and inline mobile playback
- Voice duration, MIME type, source filename, and storage path tracking
- Administrator playback, filtering, manual archiving, deletion, file-size display, and missing-file indication
- Optional FFmpeg transcoding with a configurable executable, bitrate, timeout, output profile, and source fallback
- Recommended AAC-LC audio in an M4A container for iPhone, Safari, Android, and desktop playback
- Optional MP4 profile with a small H.264 baseline video track and AAC-LC audio
- Original browser recording retained when transcoding is disabled
- Existing voice files remain unchanged when transcoding settings are enabled or modified
### Accounts and Security
- Registration with username and password; email is not required
- Case-insensitive unique usernames using letters, numbers, underscores, and hyphens
- Bcrypt password hashing with a configurable cost
- Successful logins automatically terminate the same user's older session
- Administrator session locks that pin an account to one session token and IP until unlocked
- Optional IP binding for every active session
- Configurable session lifetime with HTTP-only, strict SameSite cookies and the secure flag under HTTPS
- Email verification by six-digit code or verification link
- Verified email required only before premium checkout
- Unique verified email addresses
- Mailgun-based email delivery through a reusable cURL mailer
- Tier-configurable email two-factor authentication
- Six-digit login challenges with expiration and attempt limits
- Registration/login-only internal arithmetic CAPTCHA
- Invisible registration/login honeypot fields that reject bot-like submissions
- Optional Google reCAPTCHA v2 checkbox or v3 score/action verification
- Account view for email verification, 2FA, plan details, billing, and current feature entitlements
- Tier-configurable custom username colors
- Administrator user creation, username/color/password editing, session termination, and deletion
- CSRF protection on administrator actions
### Plans and Subscriptions
- Fully configurable plans, prices, currencies, billing intervals, descriptions, ordering, and active status
- Dashboard creation of additional tiers
- Per-tier feature toggles and archive retention
- Current configurable entitlements:
- Voice messages
- Voice bitrate
- Recording-status sending and viewing
- Automatic voice send
- Automatic voice play
- Custom username color
- Text archive retention
- Text export
- Voice archive access
- Voice export
- Email 2FA
- Stripe Price ID assignment per paid tier
- Stripe Checkout with promotion-code support
- Stripe Billing Portal for payment details and plan management
- Subscription cancellation at the end of the billing period
- Signed webhook verification and duplicate event protection
- Stripe customer, subscription, status, plan, billing-period end, and cancellation-state synchronization
- Paid statuses include active, trialing, and past due
- Global subscription visibility switch
- New plans and checkout are hidden when subscriptions are disabled
- Existing Stripe customers retain billing-management and cancellation access while new subscriptions are hidden
- Administrator tier overrides that grant features without payment and take precedence over Stripe
- Subscriber table showing email verification, effective plan, status, period end, cancellation state, and Stripe customer ID
### Archives and Exports
- Automatic prior-day SQLite archival during chat activity
- Configurable archive path using year, month, and day placeholders
- Authenticated personal archive; users can access only their own messages
- Minimum text retention of 30 days, configurable upward by tier
- Search across live and archived personal messages
- Voice history included only when the tier has voice archive access
- JSON and CSV exports when the tier has text export access
- Voice URLs included in exports only when the tier has voice export access
- Administrator archive-day browser with user and message filters
- Administrator editing and deletion of archived messages
- Manual movement of live voice clips into the appropriate daily archive
- Whole-day archive deletion with associated voice-file cleanup
- Legacy public archive browser redirects to the authenticated personal archive
### Administrator Dashboard
- Password-protected dashboard with configurable administrator password
- Overview totals for users, live messages, voice clips, daily messages, sessions, and active users
- Recent-message and recent-user panels
- Searchable live-message management by user, text, day, and message type
- Edit displayed sender and message text
- Individual, bulk, day-level, and full live-message deletion
- Dedicated voice library covering live and archived clips
- Voice filters by user, date, and source
- Voice playback, format, duration, file-size, archive, deletion, and missing-file status
- Archive list with message totals and database file sizes
- User creation and account editing
- Manual tier assignment without Stripe payment
- User kicking and deletion
- Active and expired session inspection
- Individual, expired, and all-session termination
- Plans, pricing, Stripe, Mailgun, FFmpeg, subscription visibility, and MySQL configuration
- Subscriber tracking and 30-day event totals
- Live `config.json` editor with JSON validation and formatting
- Manual MySQL mirror action
### Storage, Statistics, and Integration
- SQLite is the required primary database
- Automatic schema creation and additive migrations
- SQLite WAL mode and foreign-key enforcement
- Dynamic/searchable records stored in SQLite
- Runtime/service settings stored in dashboard-editable JSON
- Optional MySQL/MariaDB mirror of the main and archive SQLite databases
- Configurable automatic mirror interval and manual mirror execution
- Visitor and feature event tracking with salted IP hashes and truncated user agents
- Statistics can be disabled globally
- Apache and Nginx examples include protected internal paths and audio MIME mappings
- Diagnostic endpoint for PHP, SQLite, JSON configuration, storage permissions, and session support
Groups and group navigation have been removed from the application and plan system.
## Requirements ## Requirements
- PHP 8.1 or newer - PHP 8.1 or newer
- PHP extensions: `pdo_sqlite`, `curl`, and `fileinfo` - PHP extensions: `pdo_sqlite`, `curl`, and `fileinfo`
- PHP functions `proc_open` and `proc_terminate` when FFmpeg transcoding is enabled
- A web server with HTTPS for production - A web server with HTTPS for production
- Write access to `db/`, `archive/`, `uploads/voice/`, and `config.json` - Write access to `db/`, `archive/`, `uploads/voice/`, and `config.json`
- A modern browser with `MediaRecorder` for voice recording - A modern browser with `MediaRecorder` for voice recording
- Optional: FFmpeg with AAC support
- Optional: FFmpeg compiled with `libx264` for the H.264 profile
- Optional: PDO MySQL for the MySQL/MariaDB mirror - Optional: PDO MySQL for the MySQL/MariaDB mirror
## Installation ## Installation
@@ -37,13 +168,14 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
1. Place the application in the web root. 1. Place the application in the web root.
2. Make the runtime directories and configuration writable by the web-server user. 2. Make the runtime directories and configuration writable by the web-server user.
3. Configure Apache with the included `.htaccess`, or adapt `nginx-example.conf`. 3. Configure Apache with the included `.htaccess`, or adapt `nginx-example.conf`.
4. Open `admin.php` and sign in with the initial password `changeme123`. 4. Visit `api/ping.php` and resolve any failed permission or PHP checks.
5. Immediately change `admin.password` in the Configuration tab. 5. Open `admin.php` and sign in with the initial password `changeme123`.
6. Open **Plans & Platform** to configure tiers, Mailgun, Stripe, and optional MySQL mirroring. 6. Immediately change `admin.password` in the Configuration tab.
7. Open **Plans & Platform** to configure tiers, Mailgun, Stripe, optional voice transcoding, and MySQL mirroring.
The SQLite schema and default Free, Plus, and Pro tiers are created automatically on first use. The SQLite schema and default Free, Plus, and Pro tiers are created automatically on first use.
For a simple shared-hosting deployment: For simple shared hosting:
```bash ```bash
chmod 0777 db archive uploads/voice chmod 0777 db archive uploads/voice
@@ -56,30 +188,71 @@ chown -R www-data:www-data db archive uploads/voice
chmod -R 0770 db archive uploads/voice chmod -R 0770 db archive uploads/voice
``` ```
Replace `www-data` with the PHP-FPM or web-server account. SQLite must be able to create the database plus `-wal` and `-shm` sidecar files. When the application directory itself is restricted, `database.main_db` may be an absolute path to a writable persistent directory. Replace `www-data` with the PHP-FPM or web-server account. SQLite must be able to create the database plus its `-wal` and `-shm` sidecar files. When the application directory is restricted, `database.main_db` may use an absolute path to a writable persistent directory.
## Default Tiers ## Default Tiers
The defaults are starting points and are fully editable in the dashboard. The defaults are starting points. Plans, prices, and every listed entitlement can be changed in the dashboard.
| Feature | Free | Plus | Pro | | Feature | Free | Plus | Pro |
|---|---:|---:|---:| |---|---:|---:|---:|
| Groups available | Yes | Yes | Yes | | Voice messages | Yes | Yes | Yes |
| Group members, including owner | 2 | 4 | 8 | | Voice bitrate | 64 kbps | 96 kbps | 128 kbps |
| Recording status send/view | No | Yes | Yes | | Recording status send/view | No | Yes | Yes |
| Automatic voice send | No | Yes | Yes | | Automatic voice send | No | Yes | Yes |
| Automatic voice play | No | Yes | Yes | | Automatic voice play | No | Yes | Yes |
| Custom username color | No | Yes | Yes | | Custom username color | No | Yes | Yes |
| Text archive | 30 days | 90 days | 365 days | | Text archive | 30 days | 90 days | 365 days |
| Text export | Yes | Yes | Yes | | Text export | Yes | Yes | Yes |
| Voice archive/export | No | Yes | Yes | | Voice archive | No | Yes | Yes |
| Voice export | No | Yes | Yes |
| Email 2FA | No | Yes | Yes | | Email 2FA | No | Yes | Yes |
Each tier has an explicit **Enable groups for this tier** toggle and a separate **Maximum People Per Group** value. The minimum is two people, including the group owner; disabling groups uses the toggle rather than a numeric value of zero. Administrators can assign a tier directly from **Admin > Users > Edit > Tier Override**. The override does not alter Stripe billing and remains effective until changed back to **Follow Stripe / billing status**.
Administrators can assign a tier directly from **Admin > Users > Edit > Tier Override**. The override grants that tier's features without changing Stripe billing data and remains in effect until it is changed back to **Follow Stripe / billing status**. ## Audio Configuration
Private groups can be enabled or disabled globally under **Admin > Plans & Platform > Group Availability** or **Admin > Config > Feature Availability**. Each tier also has a **Groups Available** entitlement. Disabling either control hides group navigation and channels and blocks group API access without deleting existing groups or messages. Global audio settings are under **Admin > Config** and **Admin > Plans & Platform > Voice Transcoding / FFmpeg**.
Important settings:
| Setting | Purpose |
|---|---|
| `voice.enabled` | Globally show or hide voice recording controls |
| `voice.max_duration_seconds` | Maximum accepted recording duration |
| `voice.max_upload_bytes` | Maximum uploaded voice-file size |
| `voice.auto_play_default` | Initial automatic-play preference for entitled users |
| `voice.upload_dir` | Voice-file storage path |
| `voice.transcoding.enabled` | Enable server-side FFmpeg conversion |
| `voice.transcoding.ffmpeg_path` | FFmpeg executable name or absolute path |
| `voice.transcoding.profile` | `m4a_aac` or `mp4_h264_aac` |
| `voice.transcoding.audio_bitrate_kbps` | Fallback AAC bitrate when no tier override is supplied |
| `voice.transcoding.timeout_seconds` | Conversion timeout from 5 to 300 seconds |
| `voice.transcoding.fallback_to_source` | Keep the source file if conversion fails |
Recording selection is browser-driven:
1. WebM with Opus
2. WebM
3. MP4 with AAC-LC
4. MP4
5. AAC
6. Browser default MediaRecorder format
The recorder collects one finalized blob at stop instead of concatenating short live WebM slices. This avoids Chrome-generated fragment metadata that other browsers may reject.
When FFmpeg is disabled, the accepted browser recording is stored unchanged. WebM remains the preferred default when the browser supports it. Safari and iPhone can upload MP4/AAC directly.
When FFmpeg is enabled:
- `m4a_aac` produces audio-only AAC-LC in an M4A container and is the recommended compatibility profile.
- `mp4_h264_aac` adds a 16-by-16 black H.264 baseline video track with AAC-LC audio for systems requiring conventional MP4.
- New uploads are converted; existing files are not rewritten.
- The original file can be retained automatically if FFmpeg fails.
The authenticated user's `voice_bitrate_kbps` tier entitlement controls the browser MediaRecorder target and overrides the FFmpeg fallback bitrate for new clips.
The web server must serve `.webm`, `.wav`, `.aac`, `.m4a`, and `.mp4` with the MIME mappings shown in `.htaccess` and `nginx-example.conf`.
## Mailgun ## Mailgun
@@ -89,22 +262,28 @@ Configure these fields under **Admin > Plans & Platform**:
- Sending domain - Sending domain
- From address - From address
- API base, normally `https://api.mailgun.net/v3` - API base, normally `https://api.mailgun.net/v3`
- EU API base, when applicable: `https://api.eu.mailgun.net/v3` - EU API base when applicable: `https://api.eu.mailgun.net/v3`
The reusable mailer is in `lib/mailer.php`. It sends verification links and codes, limits verification attempts, and sends 2FA codes with a direct link to the authentication screen. The reusable mailer in `lib/mailer.php` sends:
- Email verification codes and links that expire after 30 minutes
- Login 2FA codes and authentication links that expire after 10 minutes
Verification and login challenges are attempt-limited. When a verified account already has a Stripe customer, changing the verified email also updates that Stripe customer.
Mailgun API reference: <https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages> Mailgun API reference: <https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages>
## Stripe ## Stripe
1. Create recurring Stripe Prices for the paid tiers. 1. Create recurring Stripe Prices for paid tiers.
2. Enter each `price_...` ID in the matching dashboard plan. 2. Enter each `price_...` ID in the matching dashboard plan.
3. Enter the Stripe secret key and webhook signing secret. 3. Enter the Stripe secret key and webhook signing secret.
4. Add this webhook endpoint: 4. Optionally set success, cancel, and Billing Portal return URLs.
5. Add this webhook endpoint:
`https://YOUR-DOMAIN/YOUR-PATH/api/stripe-webhook.php` `https://YOUR-DOMAIN/YOUR-PATH/api/stripe-webhook.php`
5. Subscribe the endpoint to: 6. Subscribe the endpoint to:
- `checkout.session.completed` - `checkout.session.completed`
- `customer.subscription.created` - `customer.subscription.created`
@@ -115,74 +294,190 @@ Mailgun API reference: <https://documentation.mailgun.com/docs/mailgun/api-refer
Checkout is rejected until the account has a verified email. Stripe customer and subscription IDs, status, current period end, cancellation state, and selected plan are synchronized into SQLite. Checkout is rejected until the account has a verified email. Stripe customer and subscription IDs, status, current period end, cancellation state, and selected plan are synchronized into SQLite.
When `subscriptions.enabled` is off, plans and checkout are hidden from non-subscribers. Existing Stripe customers retain Billing Portal and cancellation access. When `subscriptions.enabled` is false, plans and checkout are hidden from new customers. Existing Stripe customers retain Billing Portal and cancellation access.
Stripe references: Stripe references:
- <https://docs.stripe.com/api/checkout/sessions/create> - <https://docs.stripe.com/api/checkout/sessions/create>
- <https://docs.stripe.com/webhooks> - <https://docs.stripe.com/webhooks>
## Archives ## Archives and Retention
The application archives daily messages into: The application moves prior-day live messages into daily SQLite databases:
`archive/{year}/{month}/{day}.sqlite` `archive/{year}/{month}/{day}.sqlite`
The public legacy archive browser now redirects to the authenticated **My Archive** view. Users can only query their own sent messages within their plan retention window. Free users receive text history only; voice history and voice export require the corresponding plan entitlements. User archive behavior:
- Only the authenticated user's own messages are returned.
- Retention is controlled by `text_archive_days` and cannot be configured below 30 days through the plan dashboard.
- Search covers eligible live and archived messages.
- Voice rows are excluded unless `voice_archive` is enabled for the tier.
- JSON and CSV exports require `text_export`.
- Voice URLs in exports additionally require `voice_export`.
Voice files remain in `uploads/voice/` while their metadata moves between live and archive databases. Administrator deletion actions remove the associated recording file.
Reply IDs, reply depth, parent previews, and voice bitrate metadata are retained in daily archives and personal JSON/CSV exports.
## CAPTCHA
CAPTCHA checks are scoped to registration and initial password login. Email 2FA code verification, chat messages, account updates, and dashboard actions do not invoke CAPTCHA.
Dashboard controls under **Spam Control > Registration / Login CAPTCHA** configure:
- Registration and login protection independently
- Invisible honeypot protection
- Internal arithmetic challenge difficulty, expiration, and attempt limit
- Google reCAPTCHA v2 checkbox or v3 score mode
- Google site key, secret key, expected hostname, and v3 minimum score
Google tokens are verified server-side through the Siteverify API. For v3, CyberChat also validates the expected `register` or `login` action and the configured score threshold.
Google references:
- <https://developers.google.com/recaptcha/docs/verify>
- <https://developers.google.com/recaptcha/docs/v3>
## Polling and Message Ordering
CyberChat uses one polling worker per active chat view:
- A numeric message cursor requests only records after the last acknowledged ID.
- Catch-up batches continue immediately while more messages are available.
- A message map deduplicates repeated send and poll responses.
- DOM reconciliation sorts by `created_at` and then message ID.
- Existing nodes are reordered when necessary, keeping the latest message at the bottom.
- Poll requests have a configurable abort timeout.
- Repeated failures use bounded exponential retry delays.
- Sending, reconnecting, and returning to the tab wake the worker immediately.
- The browser retains only the configured maximum number of rendered messages.
This design avoids overlapping poll requests, duplicate voice players, and temporary message-order inversions.
## Administrator Areas
| Area | Capabilities |
|---|---|
| Dashboard | Usage totals, active users, recent messages, recent users, quick links |
| Messages | Search/filter, audio playback, edit, individual/bulk/day/all deletion |
| Voice Clips | Search live and archived audio, play, inspect, archive, delete |
| Archives | Browse days, inspect file sizes, filter/edit/delete messages, delete days |
| Users | Create users, edit credentials/colors, assign tiers, kick, delete |
| Sessions | Inspect active/expired sessions and terminate one, expired, or all |
| Spam Control | Search sessions, pin or unlock accounts, configure IP binding, honeypot, internal CAPTCHA, and Google reCAPTCHA |
| Plans & Platform | Plans, pricing, bitrate, replies, Stripe, Mailgun, FFmpeg, MySQL, statistics |
| Config | Edit and validate `config.json` directly |
## Configuration Families
`config.json` is editable through **Admin > Config**. The primary groups are:
- `app`: installation base URL
- `chat`: text limits, polling, browser buffer, replies, and session restrictions
- `archive`: archive enablement and directory
- `voice`: recording, upload, playback defaults, storage, and FFmpeg
- `database`: main SQLite path, archive template, and optional MySQL mirror
- `ui`: application title and subtitle
- `security`: password, session, bcrypt, CORS, and registration/login CAPTCHA settings
- `subscriptions`: new-subscription visibility
- `mailgun`: verification and 2FA delivery
- `stripe`: Checkout, webhook, and Billing Portal settings
- `stats`: usage tracking and IP hashing
- `admin`: dashboard password
- `colors`: random user-color palette
Service credentials are stored in `config.json`; protect that file from direct web access and restrict its filesystem permissions.
## MySQL/MariaDB Mirror ## MySQL/MariaDB Mirror
SQLite remains the required primary database. The optional mirror imports the main database and all archive databases into `sqlite_mirror_rows` using: SQLite remains the required primary database. The optional mirror imports the main database and all archive databases into `sqlite_mirror_rows` using:
- source database path - Source database path
- source table - Source table
- row key - Row key
- JSON row data - JSON row data
- synchronization time - Synchronization time
Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable automatic import. A manual **Run MySQL Mirror Now** action is also available. Set a PDO MySQL DSN, username, and password in the dashboard. Enable the mirror and optionally automatic import. The configured interval has a minimum of 60 seconds. **Run MySQL Mirror Now** performs a manual synchronization.
## Statistics and Diagnostics
When `stats.enabled` is true, the application records visits, registrations, login outcomes, text messages, voice messages, and other named events. IP addresses are stored only as salted SHA-256 hashes.
The administrator dashboard shows:
- Visits during the previous 24 hours
- Unique hashed visitors during the previous 24 hours
- Paid subscriber count
- Event totals during the previous 30 days
Open `api/ping.php` to check:
- PHP version
- PDO SQLite
- `config.json` existence, writability, and JSON validity
- Main database directory and file permissions
- Archive directory permissions
- SQLite database creation and WAL support
- PHP session availability
The diagnostic endpoint exposes deployment details and should be restricted or removed after setup on sensitive installations.
## Embedding ## Embedding
```html ```html
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css"> <link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.5">
<div id="my-chat" style="width:100%;height:600px"></div> <div id="my-chat" style="width:100%;height:600px;overflow:hidden"></div>
<script> <script>
window.CYBERCHAT_BASE = '/chat'; window.CYBERCHAT_BASE = '/chat';
</script> </script>
<script src="/chat/assets/js/cyberchat-v4.js"></script> <script src="/chat/assets/js/cyberchat-app.js?v=20260609.10"></script>
<script> <script>
CyberChat.init('#my-chat'); CyberChat.init('#my-chat');
</script> </script>
``` ```
See `embed-example.html` for a complete example. The chat scrolls inside its own container and does not change the host page's body overflow. See `embed-example.html` for inline, full-screen overlay, and popout window examples.
## Main Files ## Main Files
```text ```text
admin.php Administrator dashboard admin.php Administrator dashboard and moderation tools
bootstrap.php Configuration, schema, auth, plans, archives, stats bootstrap.php Configuration, schema, auth, plans, archives, stats
config.json Runtime settings and service credentials config.json Runtime settings and service credentials
index.html Standalone chat entry page
embed-example.html Embedding example
nginx-example.conf Nginx deployment and MIME configuration
api/account.php Email, color, and 2FA settings api/account.php Email, color, and 2FA settings
api/archive.php Private personal archive and export api/archive.php Private personal archive and export
api/auth.php Registration, login, 2FA, sessions api/auth.php Registration, login, 2FA, and sessions
api/billing.php Stripe checkout, portal, and cancellation api/billing.php Stripe checkout, portal, and cancellation
api/groups.php Private group management api/config.php Public-safe frontend configuration
api/messages.php Text, voice, polling, and recording presence api/messages.php Text, voice, polling, and recording presence
api/ping.php PHP, SQLite, and permission diagnostics
api/stats.php Usage event intake
api/stripe-webhook.php Stripe event synchronization api/stripe-webhook.php Stripe event synchronization
assets/js/cyberchat-v4.js Browser application assets/css/cyberchat.css Cyberpunk and light interface themes
assets/js/cyberchat-app.js Browser chat, audio player, polling, and account UI
lib/admin_platform.php Plans and platform dashboard module
lib/admin_spam.php Session locking and anti-spam dashboard module
lib/mailer.php Reusable Mailgun sender lib/mailer.php Reusable Mailgun sender
lib/stripe.php Stripe REST client and subscription sync
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
lib/stripe.php Stripe REST client and subscription sync
lib/voice_transcoder.php Optional FFmpeg voice compatibility pipeline
``` ```
## Production Security ## Production Security
- Change the default administrator password and statistics salt. - Change the default administrator password and statistics salt.
- Use a stronger production value than the default minimum password length.
- Serve the application only over HTTPS. - Serve the application only over HTTPS.
- Keep `.htaccess` rules enabled or reproduce all deny rules in the web-server configuration. - Keep `.htaccess` rules enabled or reproduce every deny and MIME rule in the server configuration.
- Never expose `config.json`, `db/`, `archive/`, or `lib/`. - Never expose `config.json`, `db/`, `archive/`, or `lib/`.
- Use separate Stripe test and live webhook secrets. - Restrict or remove `api/ping.php` after deployment verification.
- Use separate Stripe test and live credentials and webhook secrets.
- Restrict `admin.php` by IP or additional HTTP authentication where practical. - Restrict `admin.php` by IP or additional HTTP authentication where practical.
- Back up SQLite databases and voice files together. - Limit write access to the PHP/web-server account.
- Back up the main SQLite database, archive databases, `config.json`, and voice files together.
- Test FFmpeg and Stripe configuration in a non-production environment before enabling them.
+95 -52
View File
@@ -10,9 +10,10 @@
define('ROOT_DIR', __DIR__); define('ROOT_DIR', __DIR__);
define('CONFIG_FILE', ROOT_DIR . '/config.json'); define('CONFIG_FILE', ROOT_DIR . '/config.json');
define('ADMIN_VERSION', '1.0.0'); define('ADMIN_VERSION', '2.2.0');
require_once ROOT_DIR . '/bootstrap.php'; require_once ROOT_DIR . '/bootstrap.php';
require_once ROOT_DIR . '/lib/admin_platform.php'; require_once ROOT_DIR . '/lib/admin_platform.php';
require_once ROOT_DIR . '/lib/admin_spam.php';
// ─── CHANGE THIS PASSWORD ──────────────────────────────────────────────────── // ─── CHANGE THIS PASSWORD ────────────────────────────────────────────────────
define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123')); define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123'));
@@ -25,7 +26,7 @@ session_start();
function cfg(): array { function cfg(): array {
static $c = null; static $c = null;
if ($c === null) $c = json_decode(file_get_contents(CONFIG_FILE), true) ?? []; if ($c === null) $c = getConfig();
return $c; return $c;
} }
@@ -43,11 +44,13 @@ function ensureAdminMessageColumns(PDO $d): void {
$columns = []; $columns = [];
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true; foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
foreach ([ foreach ([
'group_id' => 'INTEGER',
'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_file' => 'TEXT',
'voice_mime' => 'TEXT', 'voice_mime' => 'TEXT',
'voice_duration' => 'REAL', 'voice_duration' => 'REAL',
'voice_bitrate_kbps' => 'INTEGER',
'reply_to_id' => 'INTEGER',
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
] as $name => $definition) { ] as $name => $definition) {
if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition"); if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition");
} }
@@ -61,6 +64,9 @@ function archiveDB(string $year, string $month, string $day): ?PDO {
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
ensureAdminMessageColumns($a); ensureAdminMessageColumns($a);
removeLegacyGroupSchema($a, false);
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
return $a; return $a;
} }
@@ -76,7 +82,6 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
$a->exec("CREATE TABLE IF NOT EXISTS messages ( $a->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
group_id INTEGER,
username TEXT NOT NULL, username TEXT NOT NULL,
color TEXT NOT NULL, color TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
@@ -84,14 +89,20 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
voice_file TEXT, voice_file TEXT,
voice_mime TEXT, voice_mime TEXT,
voice_duration REAL, voice_duration REAL,
voice_bitrate_kbps INTEGER,
reply_to_id INTEGER,
reply_depth INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
day_key TEXT NOT NULL day_key TEXT NOT NULL
)"); )");
ensureAdminMessageColumns($a); ensureAdminMessageColumns($a);
removeLegacyGroupSchema($a, false);
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta ( $a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
)"); )");
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
return $a; return $a;
} }
@@ -180,22 +191,31 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrfCheck(); csrfCheck();
$act = $_POST['action'] ?? ''; $act = $_POST['action'] ?? '';
if (in_array($act, ['save_group_availability','save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) { if (in_array($act, ['save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
try { try {
$message = handleAdminPlatformAction($act); $message = handleAdminPlatformAction($act);
flash($message ?: 'Platform action completed.'); flash($message ?: 'Platform action completed.');
} catch (Throwable $e) { } catch (Throwable $e) {
flash($e->getMessage(), 'err'); flash($e->getMessage(), 'err');
} }
$returnTab = $act === 'save_group_availability' && ($_POST['return_tab'] ?? '') === 'config' redirect('tab=platform');
? 'config' : 'platform'; }
redirect('tab=' . $returnTab);
if (in_array($act, ['save_spam_settings','lock_user_session','unlock_user_session'], true)) {
try {
$message = handleAdminSpamAction($act);
flash($message ?: 'Spam control action completed.');
} catch (Throwable $e) {
flash($e->getMessage(), 'err');
}
redirect('tab=spam');
} }
// ── Messages ────────────────────────────────────────────────────────────── // ── Messages ──────────────────────────────────────────────────────────────
if ($act === 'archive_voice_clip') { if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0); $id = (int)($_POST['msg_id'] ?? 0);
$stmt = db()->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'"); $live = db();
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
$stmt->execute([$id]); $stmt->execute([$id]);
$row = $stmt->fetch(); $row = $stmt->fetch();
if (!$row) { if (!$row) {
@@ -222,11 +242,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($existingFile === false) { if ($existingFile === false) {
$insert = $adb->prepare("INSERT INTO messages $insert = $adb->prepare("INSERT INTO messages
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
$insert->execute([ $insert->execute([
$row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'], $row['message'], $row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'],
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'], $row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
$row['voice_bitrate_kbps'], $row['reply_to_id'], $row['reply_depth'],
$row['created_at'], $row['day_key'] $row['created_at'], $row['day_key']
]); ]);
} }
@@ -236,7 +258,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($adb->inTransaction()) $adb->rollBack(); if ($adb->inTransaction()) $adb->rollBack();
throw $e; throw $e;
} }
$delete = db()->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'"); $delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
$delete->execute([$id]); $delete->execute([$id]);
if ($delete->rowCount() !== 1) { if ($delete->rowCount() !== 1) {
throw new RuntimeException('The live clip changed before it could be removed.'); throw new RuntimeException('The live clip changed before it could be removed.');
@@ -252,10 +274,11 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = (int)$_POST['msg_id']; $id = (int)$_POST['msg_id'];
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d' $src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
if ($src === 'live') { if ($src === 'live') {
$row = db()->prepare("SELECT voice_file FROM messages WHERE id = ?"); $live = db();
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ?");
$row->execute([$id]); $row->execute([$id]);
deleteRecordingsForRows($row->fetchAll()); deleteRecordingsForRows($row->fetchAll());
db()->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]); $live->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
flash("Message #$id deleted."); flash("Message #$id deleted.");
} else { } else {
[$_, $day] = explode(':', $src, 2); [$_, $day] = explode(':', $src, 2);
@@ -286,7 +309,9 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? ''); flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? '');
} }
if ($src === 'live') { if ($src === 'live') {
db()->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->execute([$txt, $username, $id]); $live = db();
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")
->execute([$txt, $username, $id]);
flash("Message #$id updated."); flash("Message #$id updated.");
} else { } else {
[$_, $day] = explode(':', $src, 2); [$_, $day] = explode(':', $src, 2);
@@ -303,11 +328,12 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'delete_messages_bulk') { if ($act === 'delete_messages_bulk') {
$ids = array_map('intval', $_POST['msg_ids'] ?? []); $ids = array_map('intval', $_POST['msg_ids'] ?? []);
if ($ids) { if ($ids) {
$live = db();
$ph = implode(',', array_fill(0, count($ids), '?')); $ph = implode(',', array_fill(0, count($ids), '?'));
$rows = db()->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)"); $rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
$rows->execute($ids); $rows->execute($ids);
deleteRecordingsForRows($rows->fetchAll()); deleteRecordingsForRows($rows->fetchAll());
db()->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids); $live->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
flash(count($ids) . ' message(s) deleted.'); flash(count($ids) . ' message(s) deleted.');
} }
redirect($_POST['return_qs'] ?? ''); redirect($_POST['return_qs'] ?? '');
@@ -315,10 +341,12 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'delete_day_live') { if ($act === 'delete_day_live') {
$day = $_POST['day_key']; $day = $_POST['day_key'];
$rows = db()->prepare("SELECT voice_file FROM messages WHERE day_key = ?"); $live = db();
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
$rows->execute([$day]); $rows->execute([$day]);
deleteRecordingsForRows($rows->fetchAll()); deleteRecordingsForRows($rows->fetchAll());
$st = db()->prepare("DELETE FROM messages WHERE day_key = ?"); $st->execute([$day]); $st = $live->prepare("DELETE FROM messages WHERE day_key = ?");
$st->execute([$day]);
flash("All messages for $day deleted (" . $st->rowCount() . " rows)."); flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
redirect('tab=messages'); redirect('tab=messages');
} }
@@ -340,8 +368,9 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($act === 'clear_all_live') { if ($act === 'clear_all_live') {
deleteRecordingsForRows(db()->query("SELECT voice_file FROM messages")->fetchAll()); $live = db();
db()->exec("DELETE FROM messages"); deleteRecordingsForRows($live->query("SELECT voice_file FROM messages")->fetchAll());
$live->exec("DELETE FROM messages");
flash("All live messages cleared."); flash("All live messages cleared.");
redirect('tab=messages'); redirect('tab=messages');
} }
@@ -456,11 +485,12 @@ $tab = $_GET['tab'] ?? 'dashboard';
$stats = []; $stats = [];
if ($isLoggedIn) { if ($isLoggedIn) {
try { try {
$stats['users'] = db()->query("SELECT COUNT(*) FROM users")->fetchColumn(); $live = db();
$stats['messages'] = db()->query("SELECT COUNT(*) FROM messages")->fetchColumn(); $stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
$stats['voice'] = db()->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn(); $stats['messages'] = $live->query("SELECT COUNT(*) FROM messages")->fetchColumn();
$stats['sessions'] = db()->query("SELECT COUNT(*) FROM sessions")->fetchColumn(); $stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
$stats['today'] = db()->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn(); $stats['sessions'] = $live->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
$stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn();
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600; $timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn(); $stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; } } catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
@@ -476,7 +506,10 @@ if ($isLoggedIn) {
$day = "{$m[1]}-{$m[2]}-{$m[3]}"; $day = "{$m[1]}-{$m[2]}-{$m[3]}";
$sz = filesize($f); $sz = filesize($f);
$cnt = 0; $cnt = 0;
try { $x = new PDO('sqlite:'.$f); $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn(); } catch(Exception $e){} try {
$x = archiveDB($m[1], $m[2], $m[3]);
if ($x) $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn();
} catch(Exception $e) {}
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]]; $archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
} }
} }
@@ -866,6 +899,9 @@ td.actions{white-space:nowrap;width:1px}
<a class="sb-link <?= $tab==='sessions'?'active':'' ?>" href="?tab=sessions"> <a class="sb-link <?= $tab==='sessions'?'active':'' ?>" href="?tab=sessions">
<span class="ico">⚡</span><span>Sessions</span> <span class="ico">⚡</span><span>Sessions</span>
</a> </a>
<a class="sb-link <?= $tab==='spam'?'active':'' ?>" href="?tab=spam">
<span class="ico">!</span><span>Spam Control</span>
</a>
<a class="sb-link <?= $tab==='platform'?'active':'' ?>" href="?tab=platform"> <a class="sb-link <?= $tab==='platform'?'active':'' ?>" href="?tab=platform">
<span class="ico">◆</span><span>Plans & Platform</span> <span class="ico">◆</span><span>Plans & Platform</span>
@@ -945,7 +981,9 @@ td.actions{white-space:nowrap;width:1px}
</div> </div>
<div class="panel-body np"> <div class="panel-body np">
<?php <?php
$recent = db()->query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id ORDER BY m.created_at DESC LIMIT 8")->fetchAll(); $live = db();
$recent = $live->query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id
ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
if (empty($recent)): ?> if (empty($recent)): ?>
<div class="empty">NO MESSAGES YET</div> <div class="empty">NO MESSAGES YET</div>
<?php else: ?> <?php else: ?>
@@ -1023,16 +1061,17 @@ td.actions{white-space:nowrap;width:1px}
$fDay = trim($_GET['fd'] ?? ''); $fDay = trim($_GET['fd'] ?? '');
$fType = trim($_GET['type'] ?? ''); $fType = trim($_GET['type'] ?? '');
$live = db();
$where = []; $params = []; $where = []; $params = [];
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; } if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; } if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; } if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
if (in_array($fType, ['text','voice'], true)) { $where[] = "message_type = ?"; $params[] = $fType; } if (in_array($fType, ['text','voice'], true)) { $where[] = "message_type = ?"; $params[] = $fType; }
$sql = "SELECT * FROM messages" . ($where ? " WHERE ".implode(" AND ", $where) : "") . " ORDER BY created_at DESC LIMIT 500"; $sql = "SELECT * FROM messages" . ($where ? " WHERE ".implode(" AND ", $where) : "") . " ORDER BY created_at DESC LIMIT 500";
$msgs = db()->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll(); $msgs = $live->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
// Days available // Days available
$days = db()->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN); $days = $live->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
?> ?>
<div class="search-bar"> <div class="search-bar">
@@ -1099,12 +1138,14 @@ td.actions{white-space:nowrap;width:1px}
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($msg['username']) ?></td> <td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($msg['username']) ?></td>
<td> <td>
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?> <?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s</div> <div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s
<?= !empty($msg['voice_bitrate_kbps']) ? ' · ' . (int)$msg['voice_bitrate_kbps'] . ' kbps' : '' ?></div>
<audio class="admin-audio" controls preload="metadata" <audio class="admin-audio" controls preload="metadata"
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio> src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?> <?php else: ?>
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div> <div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($msg['reply_to_id'])): ?><div class="dim">Reply #<?= (int)$msg['reply_to_id'] ?> · depth <?= (int)$msg['reply_depth'] ?></div><?php endif; ?>
</td> </td>
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td> <td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
<td class="actions"> <td class="actions">
@@ -1176,6 +1217,7 @@ td.actions{white-space:nowrap;width:1px}
$voiceRows = []; $voiceRows = [];
if ($vSource !== 'archive') { if ($vSource !== 'archive') {
$live = db();
$vWhere = ["message_type = 'voice'"]; $vWhere = ["message_type = 'voice'"];
$vParams = []; $vParams = [];
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; } if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
@@ -1212,8 +1254,9 @@ td.actions{white-space:nowrap;width:1px}
usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at'])); usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at']));
$voiceRows = array_slice($voiceRows, 0, 500); $voiceRows = array_slice($voiceRows, 0, 500);
$live = db();
$voiceDays = array_unique(array_merge( $voiceDays = array_unique(array_merge(
db()->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN), $live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
array_column($archives, 'day') array_column($archives, 'day')
)); ));
rsort($voiceDays); rsort($voiceDays);
@@ -1375,12 +1418,14 @@ td.actions{white-space:nowrap;width:1px}
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px"><?= esc($msg['username']) ?></td> <td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px"><?= esc($msg['username']) ?></td>
<td> <td>
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?> <?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s</div> <div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s
<?= !empty($msg['voice_bitrate_kbps']) ? ' · ' . (int)$msg['voice_bitrate_kbps'] . ' kbps' : '' ?></div>
<audio class="admin-audio" controls preload="metadata" <audio class="admin-audio" controls preload="metadata"
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio> src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?> <?php else: ?>
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div> <div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($msg['reply_to_id'])): ?><div class="dim">Reply #<?= (int)$msg['reply_to_id'] ?> · depth <?= (int)$msg['reply_depth'] ?></div><?php endif; ?>
</td> </td>
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td> <td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
<td class="actions"> <td class="actions">
@@ -1506,7 +1551,8 @@ td.actions{white-space:nowrap;width:1px}
</div> </div>
<?php <?php
$allUsers = db()->query(" $live = db();
$allUsers = $live->query("
SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name, SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name,
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count, (SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count,
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
@@ -1663,6 +1709,9 @@ td.actions{white-space:nowrap;width:1px}
</div> </div>
<?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?> <?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?>
<?php elseif ($tab === 'spam'): ?>
<?php renderAdminSpamTab(); ?>
<?php elseif ($tab === 'platform'): ?> <?php elseif ($tab === 'platform'): ?>
<?php renderAdminPlatformTab(); ?> <?php renderAdminPlatformTab(); ?>
@@ -1673,22 +1722,6 @@ td.actions{white-space:nowrap;width:1px}
<p>// edit config.json live — changes take effect immediately</p> <p>// edit config.json live — changes take effect immediately</p>
</div> </div>
<div class="panel">
<div class="panel-head"><span class="panel-title">// FEATURE AVAILABILITY</span></div>
<div class="panel-body">
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_group_availability">
<input type="hidden" name="return_tab" value="config">
<label class="cb-row">
<input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
<span><strong>Groups Enabled</strong> — master switch for private group navigation, channels, and API access.</span>
</label>
<button type="submit" class="btn btn-g">SAVE GROUP AVAILABILITY</button>
</form>
</div>
</div>
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<span class="panel-title">// config.json</span> <span class="panel-title">// config.json</span>
@@ -1720,13 +1753,23 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr> <tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr>
<tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr> <tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr>
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr> <tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
<tr><td class="mono">chat.poll_max_backoff_ms</td><td class="dim">integer</td><td>Maximum delay after repeated poll failures</td></tr>
<tr><td class="mono">chat.max_rendered_messages</td><td class="dim">integer</td><td>Maximum chat rows retained in the browser</td></tr>
<tr><td class="mono">chat.replies_enabled</td><td class="dim">bool</td><td>Enable reply-to and threaded display</td></tr>
<tr><td class="mono">chat.reply_max_depth</td><td class="dim">integer</td><td>Maximum nested reply depth</td></tr>
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr> <tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr> <tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr> <tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr> <tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr> <tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
<tr><td class="mono">groups.enabled</td><td class="dim">bool</td><td>Global master switch for private groups</td></tr> <tr><td class="mono">voice.transcoding.enabled</td><td class="dim">bool</td><td>Use FFmpeg for uploaded voice clips</td></tr>
<tr><td class="mono">voice.transcoding.ffmpeg_path</td><td class="dim">string</td><td>FFmpeg executable name or absolute path</td></tr>
<tr><td class="mono">voice.transcoding.profile</td><td class="dim">string</td><td>m4a_aac or mp4_h264_aac</td></tr>
<tr><td class="mono">voice.transcoding.audio_bitrate_kbps</td><td class="dim">integer</td><td>AAC output bitrate from 48 to 320 kbps</td></tr>
<tr><td class="mono">voice.transcoding.timeout_seconds</td><td class="dim">integer</td><td>FFmpeg upload conversion timeout</td></tr>
<tr><td class="mono">voice.transcoding.fallback_to_source</td><td class="dim">bool</td><td>Retain WebM/MP4 source when conversion fails</td></tr>
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr> <tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
<tr><td class="mono">security.captcha.*</td><td class="dim">object</td><td>Registration/login honeypot, internal, and Google CAPTCHA settings</td></tr>
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr> <tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr> <tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr>
<tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr> <tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr>
+25 -5
View File
@@ -14,9 +14,15 @@ if ($action === 'export') {
$format = strtolower((string)($_GET['format'] ?? 'json')); $format = strtolower((string)($_GET['format'] ?? 'json'));
$export = array_map(function(array $row) use ($canVoiceExport): array { $export = array_map(function(array $row) use ($canVoiceExport): array {
$item = [ $item = [
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null, 'id' => (int)$row['id'],
'username' => $row['username'], 'message' => $row['message'], 'username' => $row['username'], 'message' => $row['message'],
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']), 'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
'reply_depth' => (int)($row['reply_depth'] ?? 0),
'reply_username' => $row['reply_username'] ?? null,
'reply_message' => $row['reply_message'] ?? null,
'reply_message_type' => $row['reply_message_type'] ?? null,
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
]; ];
if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) { if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) {
$item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($row['voice_file']); $item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($row['voice_file']);
@@ -28,10 +34,17 @@ if ($action === 'export') {
header('Content-Type: text/csv; charset=utf-8'); header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"'); header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
fputcsv($out, ['id', 'group_id', 'username', 'message', 'type', 'created_at', 'voice_url']); fputcsv($out, [
'id', 'username', 'message', 'type', 'created_at', 'reply_to_id',
'reply_depth', 'reply_username', 'reply_message', 'reply_message_type',
'voice_bitrate_kbps', 'voice_url',
]);
foreach ($export as $row) fputcsv($out, [ foreach ($export as $row) fputcsv($out, [
$row['id'], $row['group_id'], $row['username'], $row['message'], $row['type'], $row['id'], $row['username'], $row['message'], $row['type'],
$row['created_at'], $row['voice_url'] ?? '', $row['created_at'], $row['reply_to_id'] ?? '', $row['reply_depth'],
$row['reply_username'] ?? '', $row['reply_message'] ?? '',
$row['reply_message_type'] ?? '', $row['voice_bitrate_kbps'] ?? '',
$row['voice_url'] ?? '',
]); ]);
fclose($out); fclose($out);
exit; exit;
@@ -44,10 +57,17 @@ if ($action === 'export') {
$payload = array_map(function(array $row): array { $payload = array_map(function(array $row): array {
return [ return [
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null, 'id' => (int)$row['id'],
'message' => $row['message'], 'message_type' => $row['message_type'], 'message' => $row['message'], 'message_type' => $row['message_type'],
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null, 'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_mime' => $row['voice_mime'] ?? null,
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null, 'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
'reply_depth' => (int)($row['reply_depth'] ?? 0),
'reply_username' => $row['reply_username'] ?? null,
'reply_message' => $row['reply_message'] ?? null,
'reply_message_type' => $row['reply_message_type'] ?? null,
'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'], 'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'],
]; ];
}, $rows); }, $rows);
+10 -9
View File
@@ -6,6 +6,7 @@ sendCorsHeaders();
$action = $_POST['action'] ?? $_GET['action'] ?? ''; $action = $_POST['action'] ?? $_GET['action'] ?? '';
match ($action) { match ($action) {
'captcha_challenge' => captchaChallenge(),
'register' => registerUser(), 'register' => registerUser(),
'login' => loginUser(), 'login' => loginUser(),
'verify_2fa' => verifyTwoFactor(), 'verify_2fa' => verifyTwoFactor(),
@@ -14,7 +15,13 @@ match ($action) {
default => jsonResponse(['error' => 'Unknown action'], 400), default => jsonResponse(['error' => 'Unknown action'], 400),
}; };
function captchaChallenge(): never {
$operation = trim((string)($_GET['operation'] ?? $_POST['operation'] ?? ''));
jsonResponse(createAuthCaptchaChallenge($operation));
}
function registerUser(): never { function registerUser(): never {
validateAuthCaptcha('register');
$db = getDB(); $db = getDB();
$username = trim((string)($_POST['username'] ?? '')); $username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? ''); $password = (string)($_POST['password'] ?? '');
@@ -44,6 +51,7 @@ function registerUser(): never {
} }
function loginUser(): never { function loginUser(): never {
validateAuthCaptcha('login');
$db = getDB(); $db = getDB();
$username = trim((string)($_POST['username'] ?? '')); $username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? ''); $password = (string)($_POST['password'] ?? '');
@@ -56,15 +64,7 @@ function loginUser(): never {
jsonResponse(['error' => 'Invalid username or password'], 401); jsonResponse(['error' => 'Invalid username or password'], 401);
} }
if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) { assertUserSessionLoginAllowed($db, (int)$user['id']);
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$existingStmt = $db->prepare("SELECT id,ip FROM sessions WHERE user_id=? AND last_active>?");
$existingStmt->execute([$user['id'], $cutoff]);
$existing = $existingStmt->fetch();
if ($existing && getConfigVal('chat.session_lock_ip', true) && $existing['ip'] !== clientIP()) {
jsonResponse(['error' => 'Already logged in from another location'], 403);
}
}
if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) { if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) {
if (empty($user['email_verified_at']) || empty($user['email'])) { if (empty($user['email_verified_at']) || empty($user['email'])) {
@@ -101,6 +101,7 @@ function verifyTwoFactor(): never {
if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]); if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]);
jsonResponse(['error' => 'Invalid or expired login code'], 401); jsonResponse(['error' => 'Invalid or expired login code'], 401);
} }
assertUserSessionLoginAllowed($db, (int)$row['user_id']);
$db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]); $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]);
createUserSession($db, (int)$row['user_id']); createUserSession($db, (int)$row['user_id']);
trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']); trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']);
+18 -2
View File
@@ -10,16 +10,32 @@ jsonResponse([
'max_username_length' => $config['chat']['max_username_length'] ?? 12, 'max_username_length' => $config['chat']['max_username_length'] ?? 12,
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000, 'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000, 'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000,
'max_rendered_messages' => $config['chat']['max_rendered_messages'] ?? 500,
'replies_enabled' => $config['chat']['replies_enabled'] ?? true,
'reply_max_depth' => $config['chat']['reply_max_depth'] ?? 4,
], ],
'ui' => $config['ui'] ?? [], 'ui' => $config['ui'] ?? [],
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4], 'security' => [
'min_password_length' => $config['security']['min_password_length'] ?? 4,
'captcha' => [
'registration_enabled' => $config['security']['captcha']['registration_enabled'] ?? false,
'login_enabled' => $config['security']['captcha']['login_enabled'] ?? false,
'honeypot_enabled' => $config['security']['captcha']['honeypot']['enabled'] ?? true,
'internal_enabled' => $config['security']['captcha']['internal']['enabled'] ?? false,
'google_enabled' => $config['security']['captcha']['google']['enabled'] ?? false,
'google_version' => $config['security']['captcha']['google']['version'] ?? 'v2',
'google_site_key' => $config['security']['captcha']['google']['site_key'] ?? '',
],
],
'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []], 'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []],
'voice' => [ 'voice' => [
'enabled' => $config['voice']['enabled'] ?? true, 'enabled' => $config['voice']['enabled'] ?? true,
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60, 'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912, 'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
'auto_play_default' => $config['voice']['auto_play_default'] ?? true, 'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
'transcoding_enabled' => $config['voice']['transcoding']['enabled'] ?? false,
'output_profile' => $config['voice']['transcoding']['profile'] ?? 'm4a_aac',
], ],
'groups' => ['enabled' => $config['groups']['enabled'] ?? true],
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true], 'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
]); ]);
-125
View File
@@ -1,125 +0,0 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
$planGroupsEnabled = (bool)featureValue($user, 'groups_available', true);
if (!groupsAvailableForUser($user)) {
$error = groupsEnabled()
? 'Private groups are not available on your tier'
: 'Private groups are currently disabled';
if ($action === 'list') {
jsonResponse([
'groups' => [],
'member_limit' => 0,
'enabled' => false,
'platform_enabled' => groupsEnabled(),
'plan_enabled' => $planGroupsEnabled,
]);
}
jsonResponse([
'error' => $error,
'groups_enabled' => false,
'groups_platform_enabled' => groupsEnabled(),
], 403);
}
switch ($action) {
case 'list':
jsonResponse([
'groups' => userGroups((int)$user['id']),
'member_limit' => (int)featureValue($user, 'group_member_limit', 2),
'enabled' => true,
'platform_enabled' => true,
'plan_enabled' => true,
]);
case 'create':
$name = trim((string)($_POST['name'] ?? ''));
$names = array_values(array_unique(array_filter(array_map('trim', explode(',', (string)($_POST['members'] ?? ''))))));
if ($name === '' || strlen($name) > 40) jsonResponse(['error' => 'Group name must be 1-40 characters'], 400);
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
if (count($names) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
$memberIds = [];
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
foreach ($names as $username) {
if (strcasecmp($username, $user['username']) === 0) continue;
$lookup->execute([$username]);
$id = (int)$lookup->fetchColumn();
if (!$id) jsonResponse(['error' => "User '$username' was not found"], 404);
$memberIds[$id] = $id;
}
if (!$memberIds) jsonResponse(['error' => 'Add at least one other user to create a group'], 400);
if (count($memberIds) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
$db = getDB();
$db->beginTransaction();
$db->prepare("INSERT INTO chat_groups (owner_id,name,created_at,updated_at) VALUES (?,?,?,?)")
->execute([$user['id'], $name, time(), time()]);
$groupId = (int)$db->lastInsertId();
$insert = $db->prepare("INSERT INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,?,?)");
$insert->execute([$groupId, $user['id'], 'owner', time()]);
foreach ($memberIds as $id) $insert->execute([$groupId, $id, 'member', time()]);
$db->commit();
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'add_member':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$username = trim((string)($_POST['username'] ?? ''));
$groupStmt = getDB()->prepare("SELECT * FROM chat_groups WHERE id=? AND owner_id=?");
$groupStmt->execute([$groupId, $user['id']]);
$group = $groupStmt->fetch();
if (!$group) jsonResponse(['error' => 'Only the group owner can add members'], 403);
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
$countStmt = getDB()->prepare("SELECT COUNT(*) FROM group_members WHERE group_id=?");
$countStmt->execute([$groupId]);
if ((int)$countStmt->fetchColumn() >= $limit) jsonResponse(['error' => "This group has reached your $limit-member limit"], 403);
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
$lookup->execute([$username]);
$memberId = (int)$lookup->fetchColumn();
if (!$memberId) jsonResponse(['error' => 'User not found'], 404);
getDB()->prepare("INSERT OR IGNORE INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,'member',?)")
->execute([$groupId, $memberId, time()]);
getDB()->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([time(), $groupId]);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'remove_member':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$memberId = (int)($_POST['user_id'] ?? 0);
$stmt = getDB()->prepare("SELECT owner_id FROM chat_groups WHERE id=?");
$stmt->execute([$groupId]);
$ownerId = (int)$stmt->fetchColumn();
if (!$ownerId) jsonResponse(['error' => 'Group not found'], 404);
if ($ownerId !== (int)$user['id'] && $memberId !== (int)$user['id']) jsonResponse(['error' => 'Not allowed'], 403);
if ($memberId === $ownerId) jsonResponse(['error' => 'The owner must delete the group instead'], 400);
getDB()->prepare("DELETE FROM group_members WHERE group_id=? AND user_id=?")->execute([$groupId, $memberId]);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'delete':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$db = getDB();
$owner = $db->prepare("SELECT 1 FROM chat_groups WHERE id=? AND owner_id=?");
$owner->execute([$groupId, $user['id']]);
if (!$owner->fetchColumn()) jsonResponse(['error' => 'Only the group owner can delete this group'], 403);
$voice = $db->prepare("SELECT voice_file FROM messages WHERE group_id=? AND voice_file IS NOT NULL");
$voice->execute([$groupId]);
$voiceFiles = $voice->fetchAll();
$db->beginTransaction();
try {
$db->prepare("DELETE FROM recording_status WHERE group_key=?")->execute([groupKey($groupId)]);
$db->prepare("DELETE FROM messages WHERE group_id=?")->execute([$groupId]);
$db->prepare("DELETE FROM group_members WHERE group_id=?")->execute([$groupId]);
$db->prepare("DELETE FROM chat_groups WHERE id=?")->execute([$groupId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
foreach ($voiceFiles as $row) deleteVoiceFile($row['voice_file'] ?? null);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
+145 -87
View File
@@ -1,24 +1,25 @@
<?php <?php
define('CYBERCHAT_API', true); define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/voice_transcoder.php';
sendCorsHeaders(); sendCorsHeaders();
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); } try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
$action = $_POST['action'] ?? $_GET['action'] ?? ''; $action = $_POST['action'] ?? $_GET['action'] ?? '';
switch ($action) { match ($action) {
case 'send': sendTextMessage(); 'send' => sendTextMessage(),
case 'send_voice': sendVoiceMessage(); 'send_voice' => sendVoiceMessage(),
case 'recording': setRecordingStatus(); 'recording' => setRecordingStatus(),
case 'poll': pollMessages(); 'poll' => pollMessages(),
case 'history': archiveDays(); 'history' => archiveDays(),
default: jsonResponse(['error' => 'Unknown action'], 400); default => jsonResponse(['error' => 'Unknown action'], 400),
} };
function messagePayload(array $row): array { function messagePayload(array $row): array {
return [ return [
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null, 'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
'username' => $row['username'], 'username' => $row['username'],
'color' => $row['color'], 'color' => $row['color'],
'message' => $row['message'], 'message' => $row['message'],
@@ -26,104 +27,154 @@ function messagePayload(array $row): array {
'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null, 'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_mime' => $row['voice_mime'] ?? null, 'voice_mime' => $row['voice_mime'] ?? null,
'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null, 'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null,
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
'reply_depth' => isset($row['reply_depth']) ? (int)$row['reply_depth'] : 0,
'reply_username' => $row['reply_username'] ?? null,
'reply_message' => $row['reply_message'] ?? null,
'reply_message_type' => $row['reply_message_type'] ?? null,
'created_at' => (int)$row['created_at'], 'created_at' => (int)$row['created_at'],
'day_key' => $row['day_key'] ?? dayKey(), 'day_key' => $row['day_key'] ?? dayKey(),
]; ];
} }
function requestedGroup(array $user): ?int { function messageSelectSql(string $where): string {
$groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null); return "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
if ($groupId !== null && !groupsAvailableForUser($user)) { p.message_type AS reply_message_type
$error = groupsEnabled() FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id WHERE $where";
? 'Private groups are not available on your tier' }
: 'Private groups are currently disabled';
jsonResponse([ function messageById(PDO $db, int $id): array {
'error' => $error, $stmt = $db->prepare(messageSelectSql('m.id=?'));
'groups_enabled' => false, $stmt->execute([$id]);
'groups_platform_enabled' => groupsEnabled(), $row = $stmt->fetch();
], 403); if (!$row) throw new RuntimeException('Stored message could not be loaded.');
return $row;
}
function resolveReply(PDO $db): array {
$replyId = max(0, (int)($_POST['reply_to_id'] ?? 0));
if ($replyId === 0) return [null, 0];
if (!getConfigVal('chat.replies_enabled', true)) {
jsonResponse(['error' => 'Replies are disabled'], 403);
} }
requireGroupAccess($user, $groupId); $maxDepth = max(1, min(20, (int)getConfigVal('chat.reply_max_depth', 4)));
return $groupId; $stmt = $db->prepare("SELECT id,reply_depth FROM messages WHERE id=? AND day_key=?");
$stmt->execute([$replyId, dayKey()]);
$parent = $stmt->fetch();
if (!$parent) jsonResponse(['error' => 'The message being replied to is unavailable'], 404);
$depth = (int)$parent['reply_depth'] + 1;
if ($depth > $maxDepth) {
jsonResponse(['error' => "Reply depth limit reached (max $maxDepth)"], 400);
}
return [$replyId, $depth];
} }
function sendTextMessage(): never { function sendTextMessage(): never {
$user = authRequired(); $user = authRequired();
$groupId = requestedGroup($user);
$message = trim((string)($_POST['message'] ?? '')); $message = trim((string)($_POST['message'] ?? ''));
$max = (int)getConfigVal('chat.max_message_length', 500); $max = (int)getConfigVal('chat.max_message_length', 500);
if ($message === '') jsonResponse(['error' => 'Empty message'], 400); if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400); if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
$created = time();
$db = getDB(); $db = getDB();
$db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key) [$replyId, $replyDepth] = resolveReply($db);
VALUES (?,?,?,?,?,?,?)")->execute([ $db->prepare("INSERT INTO messages
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(), (user_id,username,color,message,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
$user['id'], $user['username'], $user['color'], $message,
$replyId, $replyDepth, dayKey(),
]); ]);
if ($groupId !== null) { $messageId = (int)$db->lastInsertId();
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
}
trackEvent('message_text', '/api/messages.php', (int)$user['id']); trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([ jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => $message, 'message_type' => 'text',
'created_at' => $created, 'day_key' => dayKey(),
])]);
} }
function sendVoiceMessage(): never { function sendVoiceMessage(): never {
$user = authRequired(); $user = authRequired();
$groupId = requestedGroup($user);
if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) { if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) {
jsonResponse(['error' => 'Your plan does not include voice messages'], 403); jsonResponse(['error' => 'Your plan does not include voice messages'], 403);
} }
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) jsonResponse(['error' => 'No voice clip uploaded'], 400); if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) {
jsonResponse(['error' => 'No voice clip uploaded'], 400);
}
$file = $_FILES['voice']; $file = $_FILES['voice'];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) jsonResponse(['error' => 'Voice upload failed'], 400); if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
jsonResponse(['error' => 'Voice upload failed'], 400);
}
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912); $maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400); if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) {
jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
}
$duration = (float)($_POST['duration'] ?? 0); $duration = (float)($_POST['duration'] ?? 0);
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60)); $maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
if ($duration <= 0 || $duration > $maxSeconds + 1) jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400); if ($duration <= 0 || $duration > $maxSeconds + 1) {
jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400);
}
$mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : ''; $mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : '';
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12); $header = (string)file_get_contents($file['tmp_name'], false, null, 0, 16);
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm'; if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm';
elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav'; elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav';
$allowed = ['audio/webm' => 'webm', 'video/webm' => 'webm', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/wave' => 'wav']; elseif (strlen($header) >= 8 && substr($header, 4, 4) === 'ftyp') $mime = 'audio/mp4';
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400); elseif (strlen($header) >= 2 && ord($header[0]) === 0xFF && (ord($header[1]) & 0xF0) === 0xF0) $mime = 'audio/aac';
$allowed = [
'audio/webm' => 'webm',
'video/webm' => 'webm',
'audio/wav' => 'wav',
'audio/x-wav' => 'wav',
'audio/wave' => 'wav',
'audio/mp4' => 'm4a',
'video/mp4' => 'm4a',
'audio/aac' => 'aac',
'audio/x-aac' => 'aac',
];
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WebM, MP4/M4A, AAC, or WAV'], 400);
$db = getDB();
[$replyId, $replyDepth] = resolveReply($db);
$dir = voiceUploadDir(); $dir = voiceUploadDir();
ensureWritableDirectory($dir, 'Voice storage'); ensureWritableDirectory($dir, 'Voice storage');
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime]; $token = bin2hex(random_bytes(20));
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500); $sourceFilename = $token . '.' . $allowed[$mime];
$sourcePath = $dir . '/' . $sourceFilename;
if (!move_uploaded_file($file['tmp_name'], $sourcePath)) {
jsonResponse(['error' => 'Could not store voice clip'], 500);
}
$created = time(); $sourceMime = match ($mime) {
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime; 'video/webm' => 'audio/webm',
$db = getDB(); 'video/mp4' => 'audio/mp4',
'audio/x-wav', 'audio/wave' => 'audio/wav',
'audio/x-aac' => 'audio/aac',
default => $mime,
};
$bitrate = max(48, min(320, (int)featureValue($user, 'voice_bitrate_kbps', 64)));
try {
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime, $bitrate);
} catch (Throwable $e) {
if (is_file($sourcePath)) @unlink($sourcePath);
throw $e;
}
$filename = $stored['filename'];
$storedMime = $stored['mime'];
try { try {
$db->prepare("INSERT INTO messages $db->prepare("INSERT INTO messages
(user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) (user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([ voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
$user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]', VALUES (?,?,?,?,'voice',?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
$filename, $storedMime, $duration, $created, dayKey(), $user['id'], $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, $bitrate, $replyId, $replyDepth, dayKey(),
]); ]);
if ($groupId !== null) {
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
}
} catch (Throwable $e) { } catch (Throwable $e) {
deleteVoiceFile($filename); deleteVoiceFile($filename);
throw $e; throw $e;
} }
getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?") $messageId = (int)$db->lastInsertId();
->execute([groupKey($groupId), $user['id']]); $db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
->execute([$user['id']]);
trackEvent('message_voice', '/api/messages.php', (int)$user['id']); trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([ jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice',
'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration,
'created_at' => $created, 'day_key' => dayKey(),
])]);
} }
function setRecordingStatus(): never { function setRecordingStatus(): never {
@@ -131,64 +182,71 @@ function setRecordingStatus(): never {
if (!featureValue($user, 'recording_status', false)) { if (!featureValue($user, 'recording_status', false)) {
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403); jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
} }
$groupId = requestedGroup($user);
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN); $active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
$db = getDB(); $db = getDB();
if ($active) { if ($active) {
$db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?) $db->prepare("INSERT INTO recording_status (channel_key,user_id,expires_at) VALUES ('public',?,?)
ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at") ON CONFLICT(channel_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
->execute([groupKey($groupId), $user['id'], time() + 10]); ->execute([$user['id'], time() + 10]);
} else { } else {
$db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?") $db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
->execute([groupKey($groupId), $user['id']]); ->execute([$user['id']]);
} }
jsonResponse(['success' => true]); jsonResponse(['success' => true]);
} }
function pollMessages(): never { function pollMessages(): never {
$user = authRequired(); $user = authRequired();
$groupId = requestedGroup($user);
$since = max(0, (int)($_GET['since'] ?? 0)); $since = max(0, (int)($_GET['since'] ?? 0));
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100))); $limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
$db = getDB(); $db = getDB();
$whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?';
$params = $groupId === null ? [dayKey()] : [dayKey(), $groupId];
if ($since === 0) { if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?"); $stmt = $db->prepare(messageSelectSql('m.day_key=?') . " ORDER BY m.id DESC LIMIT ?");
$params[] = $limit; $stmt->execute([dayKey(), $limit]);
$stmt->execute($params);
$rows = array_reverse($stmt->fetchAll());
} else {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?");
$params[] = $since;
$params[] = $limit;
$stmt->execute($params);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
usort($rows, 'compareMessagesChronologically');
$hasMore = false;
} else {
$stmt = $db->prepare(messageSelectSql('m.day_key=? AND m.id>?') . " ORDER BY m.id LIMIT ?");
$stmt->execute([dayKey(), $since, $limit + 1]);
$rows = $stmt->fetchAll();
$hasMore = count($rows) > $limit;
if ($hasMore) $rows = array_slice($rows, 0, $limit);
} }
$cursor = $since;
foreach ($rows as $row) $cursor = max($cursor, (int)$row['id']);
$cutoff = time() - 300; $cutoff = time() - 300;
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?"); $onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
$onlineStmt->execute([$cutoff]); $onlineStmt->execute([$cutoff]);
$recording = []; $recording = [];
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]); $db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
if (featureValue($user, 'recording_status', false)) { if (featureValue($user, 'recording_status', false)) {
$recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r $recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r
JOIN users u ON u.id=r.user_id WHERE r.group_key=? AND r.expires_at>? AND r.user_id!=?"); JOIN users u ON u.id=r.user_id
$recordStmt->execute([groupKey($groupId), time(), $user['id']]); WHERE r.channel_key='public' AND r.expires_at>? AND r.user_id!=?");
$recordStmt->execute([time(), $user['id']]);
$recording = $recordStmt->fetchAll(); $recording = $recordStmt->fetchAll();
} }
jsonResponse([ jsonResponse([
'messages' => array_map('messagePayload', $rows), 'messages' => array_map('messagePayload', $rows),
'cursor' => $cursor,
'has_more' => $hasMore,
'online' => (int)$onlineStmt->fetchColumn(), 'online' => (int)$onlineStmt->fetchColumn(),
'recording' => $recording, 'recording' => $recording,
'groups' => groupsAvailableForUser($user) ? userGroups((int)$user['id']) : [],
'groups_enabled' => groupsAvailableForUser($user),
'groups_platform_enabled' => groupsEnabled(),
'group_id' => $groupId,
'server_time' => time(), 'server_time' => time(),
]); ]);
} }
function compareMessagesChronologically(array $a, array $b): int {
return ((int)$a['created_at'] <=> (int)$b['created_at'])
?: ((int)$a['id'] <=> (int)$b['id']);
}
function archiveDays(): never { function archiveDays(): never {
$user = authRequired(); $user = authRequired();
$rows = personalArchiveRows($user, '', 1000); $rows = personalArchiveRows($user, '', 1000);
+118 -8
View File
@@ -67,7 +67,7 @@ input, button { font-family: inherit; }
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 360px; min-height: 0;
background: var(--bg0); background: var(--bg0);
color: var(--t0); color: var(--t0);
font-family: var(--ui); font-family: var(--ui);
@@ -76,6 +76,8 @@ input, button { font-family: inherit; }
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: var(--r2); border-radius: var(--r2);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain;
contain: layout paint;
position: relative; position: relative;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -222,6 +224,7 @@ input, button { font-family: inherit; }
/* ─── Body area ──────────────────────────────────────────────────────────── */ /* ─── Body area ──────────────────────────────────────────────────────────── */
.cc-body { .cc-body {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -283,7 +286,7 @@ input, button { font-family: inherit; }
.cc-auth-panel { .cc-auth-panel {
width: 100%; width: 100%;
max-width: 340px; max-width: 380px;
background: var(--bg1); background: var(--bg1);
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: var(--r2); border-radius: var(--r2);
@@ -505,6 +508,50 @@ input, button { font-family: inherit; }
opacity: 1; opacity: 1;
} }
.cc-captcha-trap {
position: absolute !important;
left: -10000px !important;
top: auto !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
.cc-captcha-block {
margin: 4px 0 13px;
padding: 10px;
border: 1px solid var(--bd);
background: var(--bg2);
}
.cc-captcha-row {
display: grid;
grid-template-columns: minmax(72px, auto) 1fr auto;
align-items: center;
gap: 7px;
color: var(--g);
font: 11px var(--mono);
}
.cc-captcha-row .cc-input {
min-width: 0;
padding: 6px 8px;
}
.cc-captcha-refresh {
height: 31px;
padding: 0 8px;
border: 1px solid var(--b);
background: transparent;
color: var(--b);
font: 8px var(--mono);
}
.cc-google-captcha { margin-top: 10px; min-height: 78px; }
.cc-captcha-note { margin-top: 7px; color: var(--t1); font: 8px var(--mono); }
/* /*
CHAT SCREEN CHAT SCREEN
*/ */
@@ -585,7 +632,10 @@ input, button { font-family: inherit; }
/* Messages */ /* Messages */
.cc-messages { .cc-messages {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 6px 0 4px; padding: 6px 0 4px;
@@ -604,6 +654,8 @@ input, button { font-family: inherit; }
} }
.cc-msg:hover { background: rgba(255,255,255,0.02); } .cc-msg:hover { background: rgba(255,255,255,0.02); }
.cc-msg-thread { padding-left: calc(12px + var(--cc-reply-indent, 0px)); }
.cc-msg-focus { background: rgba(0,184,255,0.12); box-shadow: inset 2px 0 0 var(--b); }
.cc-msg-time { .cc-msg-time {
font-size: 9px; font-size: 9px;
@@ -641,6 +693,37 @@ input, button { font-family: inherit; }
.cc-msg-own .cc-msg-text { color: #ddeeff; } .cc-msg-own .cc-msg-text { color: #ddeeff; }
.cc-reply-ref {
display: flex;
max-width: min(100%, 470px);
margin: 0 0 3px;
padding: 3px 6px;
gap: 6px;
border: 0;
border-left: 2px solid var(--v);
background: rgba(191,95,255,0.07);
color: var(--t1);
font: 9px var(--mono);
text-align: left;
}
.cc-reply-ref b { color: var(--v); flex-shrink: 0; }
.cc-reply-ref span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cc-msg-reply {
align-self: center;
margin-left: 7px;
padding: 2px 5px;
border: 1px solid transparent;
background: transparent;
color: var(--t2);
font: 8px var(--mono);
opacity: 0;
}
.cc-msg:hover .cc-msg-reply,
.cc-msg-reply:focus { opacity: 1; border-color: var(--bd); color: var(--b); }
.cc-voice-message { .cc-voice-message {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1040,6 +1123,28 @@ input, button { font-family: inherit; }
.cc-voice-action.send { border-color: var(--g); color: var(--g); } .cc-voice-action.send { border-color: var(--g); color: var(--g); }
.cc-voice-action:disabled { opacity: 0.35; cursor: not-allowed; } .cc-voice-action:disabled { opacity: 0.35; cursor: not-allowed; }
.cc-reply-context {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 5px 10px;
border-top: 1px solid rgba(191,95,255,0.35);
background: rgba(191,95,255,0.07);
color: var(--t1);
font: 8px var(--mono);
}
.cc-reply-context[hidden] { display: none; }
.cc-reply-context b { color: var(--v); }
.cc-reply-context > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cc-reply-context button {
border: 1px solid var(--r);
background: transparent;
color: var(--r);
font: 8px var(--mono);
}
/* Compose bar */ /* Compose bar */
.cc-compose { .cc-compose {
display: flex; display: flex;
@@ -1140,14 +1245,15 @@ input, button { font-family: inherit; }
.cc-auth-wrap { align-items: flex-start; } .cc-auth-wrap { align-items: flex-start; }
} }
/* ─── Full-page (index.html) embed ───────────────────────────────────────── */ /* ─── Full-page (index.html) shell ───────────────────────────────────────── */
html, body { html.cc-fullpage-document,
body.cc-fullpage {
width: 100%; height: 100%; width: 100%; height: 100%;
margin: 0; padding: 0; margin: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
#app { body.cc-fullpage #app {
width: 100%; height: 100%; width: 100%; height: 100%;
} }
@@ -1178,15 +1284,13 @@ html, body {
.cc-inline-btn.danger { color: var(--r); border-color: var(--r); } .cc-inline-btn.danger { color: var(--r); border-color: var(--r); }
.cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; } .cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; }
.cc-actions { display: flex; gap: 6px; flex-wrap: wrap; } .cc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cc-member-list { display: flex; gap: 6px; flex-wrap: wrap; }
.cc-member-list span { padding: 4px 7px; border: 1px solid var(--bd); border-radius: 2px; font: 10px var(--mono); }
.cc-member-list button { color: var(--r); background: none; border: 0; margin-left: 4px; }
.cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); } .cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); }
.cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; } .cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; }
.cc-archive-list { display: flex; flex-direction: column; gap: 6px; } .cc-archive-list { display: flex; flex-direction: column; gap: 6px; }
.cc-archive-row { display: grid; grid-template-columns: 145px 55px 1fr; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--bd); background: var(--bg1); font: 11px var(--mono); } .cc-archive-row { display: grid; grid-template-columns: 145px 55px 1fr; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--bd); background: var(--bg1); font: 11px var(--mono); }
.cc-archive-row time { color: var(--t1); font-size: 9px; } .cc-archive-row time { color: var(--t1); font-size: 9px; }
.cc-archive-row audio { display: none; } .cc-archive-row audio { display: none; }
.cc-archive-reply { margin-bottom: 5px; padding-left: 6px; border-left: 2px solid var(--v); color: var(--t1); font-size: 9px; }
.cc-type-tag { color: var(--o); font-size: 8px; text-transform: uppercase; } .cc-type-tag { color: var(--o); font-size: 8px; text-transform: uppercase; }
.cc-switch { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); } .cc-switch { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); }
.cc-switch input { accent-color: var(--g); } .cc-switch input { accent-color: var(--g); }
@@ -1206,4 +1310,10 @@ html, body {
.cc-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; } .cc-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; }
.cc-compact-select { max-width: 145px; } .cc-compact-select { max-width: 145px; }
.cc-page { padding: 12px; } .cc-page { padding: 12px; }
.cc-msg-reply { opacity: 1; }
}
@media (max-width: 360px) {
.cc-google-captcha { width: 268px; overflow: hidden; }
.cc-google-captcha > div { transform: scale(.88); transform-origin: left top; }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+313 -105
View File
@@ -56,6 +56,13 @@ function getConfig(bool $reload = false): array {
if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found'); if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found');
$config = json_decode((string)file_get_contents(CONFIG_FILE), true); $config = json_decode((string)file_get_contents(CONFIG_FILE), true);
if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . json_last_error_msg()); if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . json_last_error_msg());
if (array_key_exists('groups', $config)) {
unset($config['groups']);
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json !== false && is_writable(CONFIG_FILE)) {
file_put_contents(CONFIG_FILE, $json . PHP_EOL, LOCK_EX);
}
}
} }
return $config; return $config;
} }
@@ -69,14 +76,6 @@ function getConfigVal(string $path, mixed $default = null): mixed {
return $value; return $value;
} }
function groupsEnabled(): bool {
return (bool)getConfigVal('groups.enabled', true);
}
function groupsAvailableForUser(array $user): bool {
return groupsEnabled() && (bool)featureValue($user, 'groups_available', true);
}
function setConfigValues(array $changes): void { function setConfigValues(array $changes): void {
$config = getConfig(); $config = getConfig();
foreach ($changes as $path => $value) { foreach ($changes as $path => $value) {
@@ -173,6 +172,56 @@ function ensureColumns(PDO $db, string $table, array $additions): void {
} }
} }
function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void {
$messageColumns = tableColumns($db, 'messages');
if (isset($messageColumns['group_id'])) {
$voiceFiles = $db->query("SELECT DISTINCT voice_file FROM messages
WHERE group_id IS NOT NULL AND voice_file IS NOT NULL AND voice_file != ''")->fetchAll(PDO::FETCH_COLUMN);
$db->exec('PRAGMA foreign_keys=OFF');
try {
$db->beginTransaction();
$db->exec("DROP TABLE IF EXISTS messages_without_groups");
$db->exec("CREATE TABLE messages_without_groups (
id INTEGER PRIMARY KEY" . ($mainDatabase ? ' AUTOINCREMENT' : '') . ",
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
color TEXT NOT NULL,
message TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT 'text',
voice_file TEXT,
voice_mime TEXT,
voice_duration REAL,
voice_bitrate_kbps INTEGER,
reply_to_id INTEGER,
reply_depth INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL" .
($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . "
)");
$db->exec("INSERT INTO messages_without_groups
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key
FROM messages WHERE group_id IS NULL");
$db->exec("DROP TABLE messages");
$db->exec("ALTER TABLE messages_without_groups RENAME TO messages");
$db->exec("DROP TABLE IF EXISTS group_members");
$db->exec("DROP TABLE IF EXISTS chat_groups");
$db->commit();
foreach ($voiceFiles as $voiceFile) deleteVoiceFile((string)$voiceFile);
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
} finally {
$db->exec('PRAGMA foreign_keys=ON');
}
} else {
$db->exec("DROP TABLE IF EXISTS group_members");
$db->exec("DROP TABLE IF EXISTS chat_groups");
}
}
function initDB(PDO $db): void { function initDB(PDO $db): void {
$db->exec("CREATE TABLE IF NOT EXISTS users ( $db->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -189,6 +238,10 @@ function initDB(PDO $db): void {
subscription_period_end INTEGER, subscription_period_end INTEGER,
cancel_at_period_end INTEGER NOT NULL DEFAULT 0, cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
two_factor_enabled INTEGER NOT NULL DEFAULT 0, two_factor_enabled INTEGER NOT NULL DEFAULT 0,
session_locked INTEGER NOT NULL DEFAULT 0,
session_lock_ip TEXT,
session_lock_session_id TEXT,
session_locked_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
last_seen INTEGER last_seen INTEGER
)"); )");
@@ -203,6 +256,10 @@ function initDB(PDO $db): void {
'subscription_period_end' => 'INTEGER', 'subscription_period_end' => 'INTEGER',
'cancel_at_period_end' => 'INTEGER NOT NULL DEFAULT 0', 'cancel_at_period_end' => 'INTEGER NOT NULL DEFAULT 0',
'two_factor_enabled' => 'INTEGER NOT NULL DEFAULT 0', 'two_factor_enabled' => 'INTEGER NOT NULL DEFAULT 0',
'session_locked' => 'INTEGER NOT NULL DEFAULT 0',
'session_lock_ip' => 'TEXT',
'session_lock_session_id' => 'TEXT',
'session_locked_at' => 'INTEGER',
]); ]);
$db->exec("CREATE TABLE IF NOT EXISTS sessions ( $db->exec("CREATE TABLE IF NOT EXISTS sessions (
@@ -214,28 +271,9 @@ function initDB(PDO $db): void {
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)"); )");
$db->exec("CREATE TABLE IF NOT EXISTS chat_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS group_members (
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at INTEGER NOT NULL,
PRIMARY KEY(group_id, user_id),
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS messages ( $db->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
group_id INTEGER,
username TEXT NOT NULL, username TEXT NOT NULL,
color TEXT NOT NULL, color TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
@@ -243,18 +281,23 @@ function initDB(PDO $db): void {
voice_file TEXT, voice_file TEXT,
voice_mime TEXT, voice_mime TEXT,
voice_duration REAL, voice_duration REAL,
voice_bitrate_kbps INTEGER,
reply_to_id INTEGER,
reply_depth INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL, day_key TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(user_id) REFERENCES users(id)
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE
)"); )");
ensureColumns($db, 'messages', [ ensureColumns($db, 'messages', [
'group_id' => 'INTEGER',
'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_file' => 'TEXT',
'voice_mime' => 'TEXT', 'voice_mime' => 'TEXT',
'voice_duration' => 'REAL', 'voice_duration' => 'REAL',
'voice_bitrate_kbps' => 'INTEGER',
'reply_to_id' => 'INTEGER',
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
]); ]);
removeLegacyGroupSchema($db, true);
$db->exec("CREATE TABLE IF NOT EXISTS plans ( $db->exec("CREATE TABLE IF NOT EXISTS plans (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -315,11 +358,23 @@ function initDB(PDO $db): void {
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)"); )");
$db->exec("CREATE TABLE IF NOT EXISTS auth_captcha_challenges (
id TEXT PRIMARY KEY,
operation TEXT NOT NULL,
answer_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)");
$recordingColumns = tableColumns($db, 'recording_status');
if ($recordingColumns && !isset($recordingColumns['channel_key'])) {
$db->exec("DROP TABLE recording_status");
}
$db->exec("CREATE TABLE IF NOT EXISTS recording_status ( $db->exec("CREATE TABLE IF NOT EXISTS recording_status (
group_key TEXT NOT NULL, channel_key TEXT NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
PRIMARY KEY(group_key, user_id), PRIMARY KEY(channel_key, user_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)"); )");
$db->exec("CREATE TABLE IF NOT EXISTS visitor_events ( $db->exec("CREATE TABLE IF NOT EXISTS visitor_events (
@@ -339,11 +394,12 @@ function initDB(PDO $db): void {
)"); )");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_group ON messages(group_id, id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_reply ON messages(reply_to_id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_event_created ON visitor_events(created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_event_created ON visitor_events(created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_auth_captcha_expires ON auth_captcha_challenges(expires_at)");
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL"); $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL");
seedPlans($db); seedPlans($db);
@@ -356,29 +412,29 @@ function seedPlans(PDO $db): void {
'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.', 'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.',
'price' => 0, 'sort' => 0, 'price' => 0, 'sort' => 0,
'features' => [ 'features' => [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, 'voice_messages' => true, 'voice_bitrate_kbps' => 64,
'auto_voice_play' => false, 'groups_available' => true, 'recording_status' => false, 'auto_voice_send' => false,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30, 'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
], ],
], ],
'plus' => [ 'plus' => [
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and groups for four.', 'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.',
'price' => 499, 'sort' => 10, 'price' => 499, 'sort' => 10,
'features' => [ 'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, 'voice_messages' => true, 'voice_bitrate_kbps' => 96,
'auto_voice_play' => true, 'groups_available' => true, 'recording_status' => true, 'auto_voice_send' => true,
'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90, 'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
], ],
], ],
'pro' => [ 'pro' => [
'name' => 'Pro', 'description' => 'Expanded groups and one year of personal archives.', 'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.',
'price' => 999, 'sort' => 20, 'price' => 999, 'sort' => 20,
'features' => [ 'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, 'voice_messages' => true, 'voice_bitrate_kbps' => 128,
'auto_voice_play' => true, 'groups_available' => true, 'recording_status' => true, 'auto_voice_send' => true,
'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365, 'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
], ],
], ],
@@ -397,8 +453,9 @@ function seedPlans(PDO $db): void {
$insertFeature->execute([$planId, $key, json_encode($value)]); $insertFeature->execute([$planId, $key, json_encode($value)]);
} }
} }
$db->exec("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json) $db->exec("DELETE FROM plan_features WHERE feature_key LIKE 'group%'");
SELECT id,'groups_available','true' FROM plans"); $db->exec("UPDATE plans SET description='Configurable premium chat features.'
WHERE lower(description) LIKE '%group%'");
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn(); $freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]); $db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
} }
@@ -414,6 +471,7 @@ function plans(bool $activeOnly = false): array {
foreach ($featureStmt->fetchAll() as $feature) { foreach ($featureStmt->fetchAll() as $feature) {
$row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true); $row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true);
} }
$row['features'] = visiblePlanFeatures($row['features']);
$row['id'] = (int)$row['id']; $row['id'] = (int)$row['id'];
$row['price_cents'] = (int)$row['price_cents']; $row['price_cents'] = (int)$row['price_cents'];
$row['active'] = (bool)$row['active']; $row['active'] = (bool)$row['active'];
@@ -421,6 +479,14 @@ function plans(bool $activeOnly = false): array {
return $rows; return $rows;
} }
function visiblePlanFeatures(array $features): array {
return array_filter(
$features,
fn(string $key): bool => !str_starts_with(strtolower($key), 'group'),
ARRAY_FILTER_USE_KEY
);
}
function planById(int $planId): ?array { function planById(int $planId): ?array {
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan; foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
return null; return null;
@@ -451,7 +517,7 @@ function userPublicPayload(array $user): array {
'email_verified' => !empty($user['email_verified_at']), 'email_verified' => !empty($user['email_verified_at']),
'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']], 'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']],
'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing', 'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing',
'features' => $plan['features'], 'features' => visiblePlanFeatures($plan['features']),
'subscription' => [ 'subscription' => [
'status' => $user['subscription_status'] ?? 'free', 'status' => $user['subscription_status'] ?? 'free',
'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null, 'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null,
@@ -478,6 +544,128 @@ function clientIP(): string {
return '0.0.0.0'; return '0.0.0.0';
} }
function authCaptchaEnabledFor(string $operation): bool {
if (!in_array($operation, ['register', 'login'], true)) return false;
$key = $operation === 'register' ? 'registration_enabled' : 'login_enabled';
return (bool)getConfigVal('security.captcha.' . $key, false);
}
function createAuthCaptchaChallenge(string $operation): array {
if (!in_array($operation, ['register', 'login'], true)) {
jsonResponse(['error' => 'Invalid CAPTCHA operation'], 400);
}
if (!authCaptchaEnabledFor($operation) || !getConfigVal('security.captcha.internal.enabled', false)) {
return ['enabled' => false];
}
$difficulty = (string)getConfigVal('security.captcha.internal.difficulty', 'easy');
$upper = $difficulty === 'medium' ? 25 : 9;
$left = random_int(1, $upper);
$right = random_int(1, $upper);
$answer = $left + $right;
$ttl = max(60, min(1800, (int)getConfigVal('security.captcha.internal.ttl_seconds', 300)));
$id = bin2hex(random_bytes(24));
$hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
$db = getDB();
$db->prepare("DELETE FROM auth_captcha_challenges WHERE expires_at<=?")->execute([time()]);
$db->prepare("INSERT INTO auth_captcha_challenges
(id,operation,answer_hash,expires_at,created_at) VALUES (?,?,?,?,?)")
->execute([$id, $operation, hash_hmac('sha256', (string)$answer, $hashKey), time() + $ttl, time()]);
return [
'enabled' => true,
'token' => $id,
'question' => "$left + $right = ?",
'expires_in' => $ttl,
];
}
function authCaptchaFailure(string $reason): never {
error_log('Authentication CAPTCHA rejected: ' . $reason);
trackEvent('captcha_failed', '/api/auth.php');
jsonResponse(['error' => 'CAPTCHA verification failed'], 403);
}
function validateAuthCaptcha(string $operation): void {
if (!authCaptchaEnabledFor($operation)) return;
if (getConfigVal('security.captcha.honeypot.enabled', true)) {
$website = trim((string)($_POST['_hp_website'] ?? ''));
$contact = filter_var($_POST['_hp_contact'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($website !== '' || $contact) authCaptchaFailure('honeypot');
}
if (getConfigVal('security.captcha.internal.enabled', false)) {
$token = trim((string)($_POST['captcha_internal_token'] ?? ''));
$answer = trim((string)($_POST['captcha_internal_answer'] ?? ''));
if ($token === '' || $answer === '') authCaptchaFailure('internal');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM auth_captcha_challenges
WHERE id=? AND operation=? AND expires_at>?");
$stmt->execute([$token, $operation, time()]);
$challenge = $stmt->fetch();
$maxAttempts = max(1, min(10, (int)getConfigVal('security.captcha.internal.max_attempts', 3)));
$hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
$answerHash = hash_hmac('sha256', $answer, $hashKey);
if (!$challenge || (int)$challenge['attempts'] >= $maxAttempts
|| !hash_equals((string)$challenge['answer_hash'], $answerHash)) {
if ($challenge) {
$db->prepare("UPDATE auth_captcha_challenges SET attempts=attempts+1 WHERE id=?")
->execute([$token]);
if ((int)$challenge['attempts'] + 1 >= $maxAttempts) {
$db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]);
}
}
authCaptchaFailure('internal');
}
$db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]);
}
if (!getConfigVal('security.captcha.google.enabled', false)) return;
$secret = trim((string)getConfigVal('security.captcha.google.secret_key', ''));
$responseToken = trim((string)($_POST['captcha_google_token'] ?? ''));
if ($secret === '' || $responseToken === '') authCaptchaFailure('google');
if (!function_exists('curl_init')) {
throw new RuntimeException('Google reCAPTCHA requires the PHP cURL extension.');
}
$curl = curl_init('https://www.google.com/recaptcha/api/siteverify');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => $secret,
'response' => $responseToken,
'remoteip' => clientIP(),
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$raw = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($raw === false || $status < 200 || $status >= 300) {
error_log('Google reCAPTCHA verification failed: ' . ($error ?: 'HTTP ' . $status));
authCaptchaFailure('google');
}
$result = json_decode((string)$raw, true);
if (!is_array($result) || empty($result['success'])) authCaptchaFailure('google');
$hostname = trim((string)getConfigVal('security.captcha.google.hostname', ''));
if ($hostname !== '' && !hash_equals(strtolower($hostname), strtolower((string)($result['hostname'] ?? '')))) {
authCaptchaFailure('google');
}
if (getConfigVal('security.captcha.google.version', 'v2') === 'v3') {
$score = (float)($result['score'] ?? 0);
$minimum = max(0.0, min(1.0, (float)getConfigVal('security.captcha.google.v3_min_score', 0.5)));
if (($result['action'] ?? '') !== $operation || $score < $minimum) {
authCaptchaFailure('google');
}
}
}
function jsonResponse(array $data, int $code = 200): never { function jsonResponse(array $data, int $code = 200): never {
http_response_code($code); http_response_code($code);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -505,6 +693,16 @@ function authUser(bool $required = true): ?array {
if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401); if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401);
return null; return null;
} }
if (!empty($user['session_locked'])) {
$lockedSession = (string)($user['session_lock_session_id'] ?? '');
$lockedIp = (string)($user['session_lock_ip'] ?? '');
if ($lockedSession === '' || $lockedIp === ''
|| !hash_equals($lockedSession, (string)$user['session_id'])
|| !hash_equals($lockedIp, clientIP())) {
if ($required) jsonResponse(['error' => 'This account is locked to another session'], 423);
return null;
}
}
getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]); getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]);
return $user; return $user;
} }
@@ -515,12 +713,47 @@ function authRequired(): array {
return $user; return $user;
} }
function assertUserSessionLoginAllowed(PDO $db, int $userId): void {
$stmt = $db->prepare("SELECT session_locked,session_lock_ip,session_lock_session_id FROM users WHERE id=?");
$stmt->execute([$userId]);
$lock = $stmt->fetch();
if (!$lock || empty($lock['session_locked'])) return;
$currentSession = (string)($_COOKIE['cyberchat_sid'] ?? '');
$lockedSession = (string)($lock['session_lock_session_id'] ?? '');
$lockedIp = (string)($lock['session_lock_ip'] ?? '');
if ($currentSession === '' || $lockedSession === '' || $lockedIp === ''
|| !hash_equals($lockedSession, $currentSession)
|| !hash_equals($lockedIp, clientIP())) {
jsonResponse([
'error' => 'This account is locked to its authorized session. Contact an administrator to unlock it.'
], 423);
}
}
function createUserSession(PDO $db, int $userId): void { function createUserSession(PDO $db, int $userId): void {
$sid = bin2hex(random_bytes(32)); $sid = bin2hex(random_bytes(32));
$db->prepare("DELETE FROM sessions WHERE user_id=?")->execute([$userId]); $now = time();
$db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)") $ip = clientIP();
->execute([$sid, $userId, clientIP(), time(), time()]); $db->beginTransaction();
$db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([time(), $userId]); try {
$lockStmt = $db->prepare("SELECT session_locked FROM users WHERE id=?");
$lockStmt->execute([$userId]);
$sessionLocked = (bool)$lockStmt->fetchColumn();
$db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)")
->execute([$sid, $userId, $ip, $now, $now]);
$db->prepare("DELETE FROM sessions WHERE user_id=? AND id<>?")->execute([$userId, $sid]);
if ($sessionLocked) {
$db->prepare("UPDATE users SET last_seen=?,session_lock_ip=?,session_lock_session_id=?,session_locked_at=?
WHERE id=?")->execute([$now, $ip, $sid, $now, $userId]);
} else {
$db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([$now, $userId]);
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie('cyberchat_sid', $sid, [ setcookie('cyberchat_sid', $sid, [
'expires' => time() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600), 'expires' => time() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600),
@@ -532,45 +765,6 @@ function dayKey(): string {
return date('Y-m-d'); return date('Y-m-d');
} }
function normalizeGroupId(mixed $value): ?int {
$id = (int)$value;
return $id > 0 ? $id : null;
}
function groupKey(?int $groupId): string {
return $groupId ? 'group:' . $groupId : 'lobby';
}
function requireGroupAccess(array $user, ?int $groupId): void {
if ($groupId === null) return;
$stmt = getDB()->prepare("SELECT 1 FROM group_members WHERE group_id=? AND user_id=?");
$stmt->execute([$groupId, $user['id']]);
if (!$stmt->fetchColumn()) jsonResponse(['error' => 'You are not a member of this group'], 403);
}
function userGroups(int $userId): array {
$stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role,
(SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count,
(SELECT MAX(m.id) FROM messages m WHERE m.group_id=g.id) AS latest_message_id,
(SELECT MAX(m.created_at) FROM messages m WHERE m.group_id=g.id) AS latest_message_at
FROM chat_groups g JOIN group_members gm ON gm.group_id=g.id
WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC");
$stmt->execute([$userId]);
$groups = $stmt->fetchAll();
$members = getDB()->prepare("SELECT u.id,u.username,u.color,gm.role FROM group_members gm
JOIN users u ON u.id=gm.user_id WHERE gm.group_id=? ORDER BY gm.role='owner' DESC,u.username");
foreach ($groups as &$group) {
$group['id'] = (int)$group['id'];
$group['owner_id'] = (int)$group['owner_id'];
$group['member_count'] = (int)$group['member_count'];
$group['latest_message_id'] = (int)($group['latest_message_id'] ?? 0);
$group['latest_message_at'] = (int)($group['latest_message_at'] ?? 0);
$members->execute([$group['id']]);
$group['members'] = $members->fetchAll();
}
return $groups;
}
function voiceUploadDir(): string { function voiceUploadDir(): string {
return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice')); return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice'));
} }
@@ -595,17 +789,22 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA journal_mode=WAL'); $db->exec('PRAGMA journal_mode=WAL');
$db->exec("CREATE TABLE IF NOT EXISTS messages ( $db->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, group_id INTEGER, id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
username TEXT NOT NULL,color TEXT NOT NULL,message TEXT NOT NULL, username TEXT NOT NULL,color TEXT NOT NULL,message TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT 'text',voice_file TEXT,voice_mime TEXT, message_type TEXT NOT NULL DEFAULT 'text',voice_file TEXT,voice_mime TEXT,
voice_duration REAL,created_at INTEGER NOT NULL,day_key TEXT NOT NULL voice_duration REAL,voice_bitrate_kbps INTEGER,reply_to_id INTEGER,
reply_depth INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL,day_key TEXT NOT NULL
)"); )");
ensureColumns($db, 'messages', [ ensureColumns($db, 'messages', [
'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
'voice_bitrate_kbps' => 'INTEGER', 'reply_to_id' => 'INTEGER',
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
]); ]);
removeLegacyGroupSchema($db, false);
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)"); $db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
return $db; return $db;
} }
@@ -635,13 +834,15 @@ function archiveYesterdayIfNeeded(): void {
$archive = getArchiveDB($year, $month, $day); $archive = getArchiveDB($year, $month, $day);
$archive->beginTransaction(); $archive->beginTransaction();
$insert = $archive->prepare("INSERT OR IGNORE INTO messages $insert = $archive->prepare("INSERT OR IGNORE INTO messages
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
foreach ($rows as $row) { foreach ($rows as $row) {
$insert->execute([ $insert->execute([
$row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'], $row['id'], $row['user_id'], $row['username'], $row['color'],
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'], $row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'],
$row['voice_duration'], $row['created_at'], $row['day_key'], $row['voice_duration'], $row['voice_bitrate_kbps'], $row['reply_to_id'],
$row['reply_depth'], $row['created_at'], $row['day_key'],
]); ]);
} }
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)"); $meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)");
@@ -658,22 +859,29 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
$cutoff = time() - ($days * 86400); $cutoff = time() - ($days * 86400);
$includeVoice = (bool)featureValue($user, 'voice_archive', false); $includeVoice = (bool)featureValue($user, 'voice_archive', false);
$rows = []; $rows = [];
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?"; $db = getDB();
$sql = "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
p.message_type AS reply_message_type
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id
WHERE m.user_id=? AND m.created_at>=?";
$params = [$user['id'], $cutoff]; $params = [$user['id'], $cutoff];
if (!$includeVoice) $sql .= " AND message_type='text'"; if (!$includeVoice) $sql .= " AND m.message_type='text'";
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; } if ($search !== '') { $sql .= " AND m.message LIKE ?"; $params[] = '%' . $search . '%'; }
$stmt = getDB()->prepare($sql . " ORDER BY created_at DESC LIMIT ?"); $stmt = $db->prepare($sql . " ORDER BY m.created_at DESC LIMIT ?");
$params[] = $limit; $params[] = $limit;
$stmt->execute($params); $stmt->execute($params);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) { foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
$archive = getArchiveDB($year, $month, $day); $archive = getArchiveDB($year, $month, $day);
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?"; $archiveSql = "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
p.message_type AS reply_message_type
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id
WHERE m.user_id=? AND m.created_at>=?";
$archiveParams = [$user['id'], $cutoff]; $archiveParams = [$user['id'], $cutoff];
if (!$includeVoice) $archiveSql .= " AND message_type='text'"; if (!$includeVoice) $archiveSql .= " AND m.message_type='text'";
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; } if ($search !== '') { $archiveSql .= " AND m.message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
$archiveStmt = $archive->prepare($archiveSql . " ORDER BY created_at DESC LIMIT ?"); $archiveStmt = $archive->prepare($archiveSql . " ORDER BY m.created_at DESC LIMIT ?");
$archiveParams[] = $limit; $archiveParams[] = $limit;
$archiveStmt->execute($archiveParams); $archiveStmt->execute($archiveParams);
array_push($rows, ...$archiveStmt->fetchAll()); array_push($rows, ...$archiveStmt->fetchAll());
+35 -6
View File
@@ -7,9 +7,12 @@
"max_username_length": 12, "max_username_length": 12,
"poll_interval_ms": 2000, "poll_interval_ms": 2000,
"poll_timeout_ms": 12000, "poll_timeout_ms": 12000,
"poll_max_backoff_ms": 15000,
"messages_per_page": 100, "messages_per_page": 100,
"max_rendered_messages": 500,
"replies_enabled": true,
"reply_max_depth": 4,
"session_lock_ip": true, "session_lock_ip": true,
"session_lock_cookie": true,
"allow_guest": false "allow_guest": false
}, },
"archive": { "archive": {
@@ -22,10 +25,15 @@
"max_duration_seconds": 60, "max_duration_seconds": 60,
"max_upload_bytes": 12582912, "max_upload_bytes": 12582912,
"auto_play_default": true, "auto_play_default": true,
"upload_dir": "uploads/voice" "upload_dir": "uploads/voice",
}, "transcoding": {
"groups": { "enabled": false,
"enabled": true "ffmpeg_path": "ffmpeg",
"profile": "m4a_aac",
"audio_bitrate_kbps": 128,
"timeout_seconds": 45,
"fallback_to_source": true
}
}, },
"database": { "database": {
"main_db": "db/chat.sqlite", "main_db": "db/chat.sqlite",
@@ -50,7 +58,28 @@
"max_login_attempts": 10, "max_login_attempts": 10,
"session_timeout_hours": 24, "session_timeout_hours": 24,
"bcrypt_cost": 10, "bcrypt_cost": 10,
"allowed_origins": [] "allowed_origins": [],
"captcha": {
"registration_enabled": true,
"login_enabled": true,
"honeypot": {
"enabled": true
},
"internal": {
"enabled": true,
"difficulty": "easy",
"ttl_seconds": 300,
"max_attempts": 3
},
"google": {
"enabled": false,
"version": "v2",
"site_key": "",
"secret_key": "",
"v3_min_score": 0.5,
"hostname": ""
}
}
}, },
"subscriptions": { "subscriptions": {
"enabled": true "enabled": true
+219 -66
View File
@@ -3,112 +3,265 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CyberChat Embed Example</title> <title>CyberChat - Embed Examples</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.5">
<style> <style>
html, body { html, body {
height: auto; min-height: 100%;
min-height: 100vh;
overflow: auto;
background: var(--bg0); background: var(--bg0);
padding: 32px 20px 48px;
font-family: var(--mono);
} }
.demo-page { max-width: 860px; margin: 0 auto; } body {
padding: 32px 20px 56px;
color: var(--t0);
font-family: var(--mono);
overflow-x: hidden;
overflow-y: auto;
}
.demo-page {
width: min(100%, 960px);
margin: 0 auto;
}
.demo-header { .demo-header {
margin-bottom: 30px;
text-align: center; text-align: center;
margin-bottom: 32px;
} }
.demo-header h1 { .demo-header h1 {
font-family: var(--disp);
font-size: clamp(18px, 4vw, 32px);
background: linear-gradient(90deg, var(--g), var(--b), var(--p));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 5px;
margin-bottom: 6px; margin-bottom: 6px;
background: linear-gradient(90deg, var(--g), var(--b), var(--p));
background-clip: text;
color: transparent;
font: 700 clamp(18px, 4vw, 32px) var(--disp);
letter-spacing: 5px;
} }
.demo-header p { .demo-header p,
font-size: 10px; .demo-note {
color: var(--t1); color: var(--t1);
letter-spacing: 2px; font-size: 10px;
letter-spacing: 1px;
line-height: 1.6;
}
.demo-section {
margin-top: 26px;
} }
.demo-label { .demo-label {
font-size: 10px;
color: var(--g);
letter-spacing: 2px;
margin-bottom: 8px; margin-bottom: 8px;
color: var(--g);
font-size: 10px;
letter-spacing: 2px;
}
.demo-frame {
width: 100%;
height: min(680px, 78vh);
min-height: 420px;
overflow: hidden;
border: 1px solid var(--bd);
border-radius: 6px;
background: var(--bg1);
box-shadow: 0 18px 60px rgba(0, 0, 0, .35);
} }
/* ── THE EMBED CONTAINER — set any size you want ── */
#chat-here { #chat-here {
width: 100%; width: 100%;
height: 580px; height: 100%;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0 12px;
}
.demo-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--b);
border-radius: 3px;
background: transparent;
color: var(--b);
font: 10px var(--mono);
letter-spacing: 1px;
text-decoration: none;
}
.demo-button:hover {
background: var(--b);
color: #000;
}
.demo-button.alt {
border-color: var(--v);
color: var(--v);
}
.demo-button.alt:hover {
background: var(--v);
color: #000;
} }
.demo-code { .demo-code {
background: var(--bg1); margin-top: 12px;
padding: 18px 20px;
overflow-x: auto;
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: 6px; border-radius: 6px;
padding: 18px 20px; background: var(--bg1);
margin-top: 24px;
font-size: 12px;
color: var(--t1); color: var(--t1);
line-height: 1.9; font-size: 12px;
overflow-x: auto; line-height: 1.75;
white-space: pre; white-space: pre;
} }
.demo-code .kw { color: var(--p); } .demo-code .kw { color: var(--p); }
.demo-code .str { color: var(--g); } .demo-code .str { color: var(--g); }
.demo-code .cm { color: var(--t2); font-style: italic; } .demo-code .cm { color: var(--t1); font-style: italic; }
.fullscreen-chat {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
grid-template-rows: 42px minmax(0, 1fr);
background: var(--bg0);
}
.fullscreen-chat[hidden] {
display: none;
}
.fullscreen-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--bd);
background: var(--bg1);
color: var(--g);
font-size: 10px;
letter-spacing: 1px;
}
.fullscreen-chat iframe {
width: 100%;
height: 100%;
border: 0;
background: var(--bg0);
}
@media (max-width: 520px) {
body { padding: 18px 10px 36px; }
.demo-frame { height: 72vh; min-height: 380px; }
.demo-code { padding: 14px; font-size: 10px; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="demo-page"> <main class="demo-page">
<div class="demo-header"> <header class="demo-header">
<h1>CYBERCHAT</h1> <h1>CYBERCHAT</h1>
<p>// EMBED EXAMPLE — DROP INTO ANY PAGE</p> <p>// INLINE, FULL-SCREEN, AND POPOUT EMBED EXAMPLES</p>
</header>
<section class="demo-section">
<div class="demo-label">INLINE EMBED</div>
<p class="demo-note">The chat owns the scrolling inside this fixed-height box. The surrounding page remains independent.</p>
<div class="demo-frame">
<div id="chat-here"></div>
</div>
<div class="demo-code"><span class="cm">&lt;!-- Include once in the host page --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"/chat/assets/css/cyberchat.css?v=20260609.5"</span><span class="kw">&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:680px; overflow:hidden"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="kw">&lt;script&gt;</span>window.CYBERCHAT_BASE = <span class="str">'/chat'</span>;<span class="kw">&lt;/script&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"/chat/assets/js/cyberchat-app.js?v=20260609.10"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>CyberChat.init(<span class="str">'#my-chat'</span>);<span class="kw">&lt;/script&gt;</span></div>
</section>
<section class="demo-section">
<div class="demo-label">FULL-SCREEN EMBED</div>
<p class="demo-note">Open the standalone chat inside a full-viewport overlay. An iframe keeps the chat isolated from the host page CSS.</p>
<div class="demo-actions">
<button class="demo-button" id="open-fullscreen" type="button">OPEN FULL SCREEN</button>
</div>
<div class="demo-code"><span class="kw">&lt;div</span> id=<span class="str">"chat-overlay"</span> style=<span class="str">"position:fixed; inset:0; z-index:10000"</span><span class="kw">&gt;</span>
<span class="kw">&lt;iframe</span> src=<span class="str">"/chat/index.html?embed=fullscreen"</span>
title=<span class="str">"CyberChat"</span> style=<span class="str">"width:100%; height:100%; border:0"</span><span class="kw">&gt;&lt;/iframe&gt;</span>
<span class="kw">&lt;/div&gt;</span></div>
</section>
<section class="demo-section">
<div class="demo-label">POPOUT / POPUP EMBED</div>
<p class="demo-note">Launch the standalone chat in a resizable window. The login cookie is shared when it is opened on the same origin.</p>
<div class="demo-actions">
<a class="demo-button alt" id="open-popout" href="index.html?embed=popout"
target="cyberchat-popout" rel="noopener">OPEN POPOUT</a>
</div>
<div class="demo-code"><span class="kw">&lt;button</span> type=<span class="str">"button"</span> onclick=<span class="str">"openCyberChat()"</span><span class="kw">&gt;</span>Open chat<span class="kw">&lt;/button&gt;</span>
<span class="kw">&lt;script&gt;</span>
function openCyberChat() {
window.open(
<span class="str">'/chat/index.html?embed=popout'</span>,
<span class="str">'cyberchat-popout'</span>,
<span class="str">'popup=yes,width=480,height=720,resizable=yes,scrollbars=no'</span>
);
}
<span class="kw">&lt;/script&gt;</span></div>
</section>
</main>
<div class="fullscreen-chat" id="fullscreen-chat" hidden>
<div class="fullscreen-toolbar">
<span>CYBERCHAT // FULL SCREEN</span>
<button class="demo-button" id="close-fullscreen" type="button">CLOSE</button>
</div>
<iframe id="fullscreen-frame" title="CyberChat full-screen example"></iframe>
</div> </div>
<div class="demo-label">▸ LIVE EMBED (580px height, full width)</div> <script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-app.js?v=20260609.10"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
<!-- ── This is all you need in your page ── --> const overlay = document.getElementById('fullscreen-chat');
<div id="chat-here"></div> const frame = document.getElementById('fullscreen-frame');
document.getElementById('open-fullscreen').addEventListener('click', function() {
<div class="demo-code"><span class="cm">&lt;!-- 1. Google Fonts (optional but recommended) --&gt;</span> if (!frame.src) frame.src = 'index.html?embed=fullscreen';
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span> overlay.hidden = false;
document.body.style.overflow = 'hidden';
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span> });
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.5"</span><span class="kw">&gt;</span> document.getElementById('close-fullscreen').addEventListener('click', function() {
overlay.hidden = true;
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span> document.body.style.overflow = '';
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span> });
document.getElementById('open-popout').addEventListener('click', function(event) {
<span class="cm">&lt;!-- 4. Script + init --&gt;</span> const popup = window.open(
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.5"</span><span class="kw">&gt;&lt;/script&gt;</span> 'index.html?embed=popout',
<span class="kw">&lt;script&gt;</span> 'cyberchat-popout',
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span> 'popup=yes,width=480,height=720,resizable=yes,scrollbars=no'
CyberChat.init(<span class="str">'#my-chat'</span>); );
<span class="kw">&lt;/script&gt;</span></div> if (popup) {
</div> event.preventDefault();
popup.focus();
<script> }
window.CYBERCHAT_BASE = ''; });
</script> });
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script> </script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
});
</script>
</body> </body>
</html> </html>
+7 -4
View File
@@ -1,20 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="cc-fullpage-document">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="description" content="CyberChat — Secure Real-Time Chat"> <meta name="description" content="CyberChat — Secure Real-Time Chat">
<title>Chat @ TyClifford.com</title> <title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.5">
</head> </head>
<body> <body class="cc-fullpage">
<div id="app"></div> <div id="app"></div>
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script> <script src="assets/js/cyberchat-app.js?v=20260609.10"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app'); CyberChat.init('#app');
+56 -34
View File
@@ -1,15 +1,13 @@
<?php <?php
function handleAdminPlatformAction(string $action): ?string { function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_group_availability') {
setConfigValues(['groups.enabled' => !empty($_POST['groups_enabled'])]);
return 'Group availability saved.';
}
if ($action === 'save_platform_services') { if ($action === 'save_platform_services') {
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac';
setConfigValues([ setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']), 'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'groups.enabled' => !empty($_POST['groups_enabled']), 'chat.replies_enabled' => !empty($_POST['replies_enabled']),
'chat.reply_max_depth' => max(1, min(20, (int)($_POST['reply_max_depth'] ?? 4))),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')), 'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')), 'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')), 'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
@@ -19,6 +17,12 @@ function handleAdminPlatformAction(string $action): ?string {
'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')), 'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')),
'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')), 'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')),
'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')), 'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')),
'voice.transcoding.enabled' => !empty($_POST['voice_transcoding_enabled']),
'voice.transcoding.ffmpeg_path' => trim((string)($_POST['voice_ffmpeg_path'] ?? 'ffmpeg')) ?: 'ffmpeg',
'voice.transcoding.profile' => $voiceProfile,
'voice.transcoding.audio_bitrate_kbps' => max(48, min(320, (int)($_POST['voice_audio_bitrate'] ?? 128))),
'voice.transcoding.timeout_seconds' => max(5, min(300, (int)($_POST['voice_transcoding_timeout'] ?? 45))),
'voice.transcoding.fallback_to_source' => !empty($_POST['voice_transcoding_fallback']),
'database.mysql.enabled' => !empty($_POST['mysql_enabled']), 'database.mysql.enabled' => !empty($_POST['mysql_enabled']),
'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']), 'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']),
'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')), 'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')),
@@ -33,7 +37,7 @@ function handleAdminPlatformAction(string $action): ?string {
$db = getDB(); $db = getDB();
$plans = $_POST['plans'] ?? []; $plans = $_POST['plans'] ?? [];
$boolFeatures = [ $boolFeatures = [
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'groups_available', 'username_color', 'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
'text_export', 'voice_archive', 'voice_export', 'email_2fa', 'text_export', 'voice_archive', 'voice_export', 'email_2fa',
]; ];
$db->beginTransaction(); $db->beginTransaction();
@@ -62,7 +66,9 @@ function handleAdminPlatformAction(string $action): ?string {
foreach ($boolFeatures as $key) { foreach ($boolFeatures as $key) {
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]); $upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
} }
$upsertFeature->execute([$id, 'group_member_limit', json_encode(max(2, min(100, (int)($plan['features']['group_member_limit'] ?? 2))))]); $upsertFeature->execute([$id, 'voice_bitrate_kbps', json_encode(
max(48, min(320, (int)($plan['features']['voice_bitrate_kbps'] ?? 64)))
)]);
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]); $upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
} }
$db->commit(); $db->commit();
@@ -86,9 +92,9 @@ function handleAdminPlatformAction(string $action): ?string {
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]); ->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
$id = (int)$db->lastInsertId(); $id = (int)$db->lastInsertId();
$features = [ $features = [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, 'voice_messages' => true, 'voice_bitrate_kbps' => 64,
'auto_voice_play' => false, 'groups_available' => false, 'recording_status' => false, 'auto_voice_send' => false,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30, 'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
]; ];
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)"); $insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
@@ -120,12 +126,11 @@ function renderAdminPlatformTab(): void {
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(), 'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(), 'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(), 'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
'groups' => (int)$db->query("SELECT COUNT(*) FROM chat_groups")->fetchColumn(),
]; ];
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events $events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll(); WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$featureKeys = [ $featureKeys = [
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play',
'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa', 'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa',
]; ];
?> ?>
@@ -136,7 +141,6 @@ function renderAdminPlatformTab(): void {
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div> <div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div> <div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div> <div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
<div class="stat-card orange"><div class="stat-num"><?= $stats['groups'] ?></div><div class="stat-label">Private Groups</div></div>
</div> </div>
<form method="post"> <form method="post">
@@ -146,9 +150,14 @@ function renderAdminPlatformTab(): void {
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>> <div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div> <span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// GROUP AVAILABILITY</span></div> <div class="panel"><div class="panel-head"><span class="panel-title">// REPLY THREADS</span></div><div class="panel-body">
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>> <div class="form-row">
<span>Enable private groups. When off, group navigation and channels are hidden while existing group data is preserved.</span></label></div></div> <label class="cb-row"><input type="checkbox" name="replies_enabled" <?= getConfigVal('chat.replies_enabled', true) ? 'checked' : '' ?>>
<span>Enable reply-to controls for text and voice messages</span></label>
<div class="field"><label>Maximum Reply Depth</label><input type="number" min="1" max="20"
name="reply_max_depth" value="<?= (int)getConfigVal('chat.reply_max_depth', 4) ?>"></div>
</div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body"> <div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<div class="form-row3"> <div class="form-row3">
@@ -172,6 +181,32 @@ function renderAdminPlatformTab(): void {
<p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p> <p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p>
</div></div> </div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// VOICE TRANSCODING / FFMPEG</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="voice_transcoding_enabled"
<?= getConfigVal('voice.transcoding.enabled', false) ? 'checked' : '' ?>>
<span>Transcode uploaded voice clips for broader playback compatibility</span></label>
<div class="form-row3">
<div class="field"><label>FFmpeg Binary</label><input name="voice_ffmpeg_path"
value="<?= esc(getConfigVal('voice.transcoding.ffmpeg_path', 'ffmpeg')) ?>" placeholder="/usr/bin/ffmpeg"></div>
<div class="field"><label>Output Profile</label><select name="voice_transcoding_profile">
<?php $voiceProfile = getConfigVal('voice.transcoding.profile', 'm4a_aac'); ?>
<option value="m4a_aac" <?= $voiceProfile === 'm4a_aac' ? 'selected' : '' ?>>M4A / AAC-LC (recommended)</option>
<option value="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option>
</select></div>
<div class="field"><label>Fallback Audio Bitrate (kbps)</label><input type="number" min="48" max="320"
name="voice_audio_bitrate" value="<?= (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>Conversion Timeout (seconds)</label><input type="number" min="5" max="300"
name="voice_transcoding_timeout" value="<?= (int)getConfigVal('voice.transcoding.timeout_seconds', 45) ?>"></div>
<label class="cb-row"><input type="checkbox" name="voice_transcoding_fallback"
<?= getConfigVal('voice.transcoding.fallback_to_source', true) ? 'checked' : '' ?>>
<span>Keep the original recording if FFmpeg fails</span></label>
</div>
<p class="dim">AAC-LC in an M4A container is the recommended audio-only profile for iPhone, Safari, Android, and desktop browsers.
The H.264 profile creates a tiny black video track plus AAC audio for systems that require a conventional MP4 file.</p>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body"> <div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label> <label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label>
<label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label> <label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label>
@@ -204,29 +239,16 @@ function renderAdminPlatformTab(): void {
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div> <div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
</div> </div>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label> <label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
<div style="border:1px solid var(--bd2);border-radius:4px;padding:12px;margin:12px 0">
<div class="panel-title" style="margin-bottom:10px">// GROUPS</div>
<div class="form-row">
<label class="cb-row" style="align-self:center;margin:0">
<input type="checkbox" name="plans[<?= $plan['id'] ?>][features][groups_available]"
<?= !empty($plan['features']['groups_available']) ? 'checked' : '' ?>>
<span><strong>Enable groups for this tier</strong></span>
</label>
<div class="field">
<label>Maximum People Per Group</label>
<input type="number" min="2" max="100" step="1"
name="plans[<?= $plan['id'] ?>][features][group_member_limit]"
value="<?= max(2, (int)($plan['features']['group_member_limit'] ?? 2)) ?>">
<div class="dim" style="margin-top:5px">Minimum 2, including the group owner. Use the toggle to disable groups; no 0 value is needed.</div>
</div>
</div>
</div>
<div class="form-row3"> <div class="form-row3">
<?php foreach ($featureKeys as $key): ?> <?php foreach ($featureKeys as $key): ?>
<?php if ($key === 'text_archive_days'): ?> <?php if ($key === 'text_archive_days'): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number" <div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="30" min="30"
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div> name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div>
<?php elseif ($key === 'voice_bitrate_kbps'): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="48" max="320"
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 64) ?>"></div>
<?php else: ?> <?php else: ?>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" <label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label> <?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
+252
View File
@@ -0,0 +1,252 @@
<?php
function handleAdminSpamAction(string $action): ?string {
if ($action === 'save_spam_settings') {
$captchaVersion = (string)($_POST['captcha_google_version'] ?? 'v2');
if (!in_array($captchaVersion, ['v2', 'v3'], true)) $captchaVersion = 'v2';
$captchaDifficulty = (string)($_POST['captcha_internal_difficulty'] ?? 'easy');
if (!in_array($captchaDifficulty, ['easy', 'medium'], true)) $captchaDifficulty = 'easy';
$googleEnabled = !empty($_POST['captcha_google_enabled']);
$googleSiteKey = trim((string)($_POST['captcha_google_site_key'] ?? ''));
$googleSecretKey = trim((string)($_POST['captcha_google_secret_key'] ?? ''));
if ($googleEnabled && ($googleSiteKey === '' || $googleSecretKey === '')) {
throw new RuntimeException('Google reCAPTCHA requires both a site key and secret key.');
}
setConfigValues([
'chat.session_lock_ip' => !empty($_POST['session_ip_binding_enabled']),
'security.captcha.registration_enabled' => !empty($_POST['captcha_registration_enabled']),
'security.captcha.login_enabled' => !empty($_POST['captcha_login_enabled']),
'security.captcha.honeypot.enabled' => !empty($_POST['captcha_honeypot_enabled']),
'security.captcha.internal.enabled' => !empty($_POST['captcha_internal_enabled']),
'security.captcha.internal.difficulty' => $captchaDifficulty,
'security.captcha.internal.ttl_seconds' => max(60, min(1800, (int)($_POST['captcha_internal_ttl'] ?? 300))),
'security.captcha.internal.max_attempts' => max(1, min(10, (int)($_POST['captcha_internal_attempts'] ?? 3))),
'security.captcha.google.enabled' => $googleEnabled,
'security.captcha.google.version' => $captchaVersion,
'security.captcha.google.site_key' => $googleSiteKey,
'security.captcha.google.secret_key' => $googleSecretKey,
'security.captcha.google.v3_min_score' => max(0.0, min(1.0, (float)($_POST['captcha_google_v3_score'] ?? 0.5))),
'security.captcha.google.hostname' => trim((string)($_POST['captcha_google_hostname'] ?? '')),
]);
return 'Spam and authentication protection settings saved.';
}
if ($action === 'lock_user_session') {
$userId = (int)($_POST['user_id'] ?? 0);
$sessionId = trim((string)($_POST['session_id'] ?? ''));
$db = getDB();
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$stmt = $db->prepare("SELECT id,user_id,ip FROM sessions WHERE id=? AND user_id=? AND last_active>?");
$stmt->execute([$sessionId, $userId, $cutoff]);
$session = $stmt->fetch();
if (!$session) throw new RuntimeException('The selected session is no longer available.');
$db->beginTransaction();
try {
$db->prepare("DELETE FROM sessions WHERE user_id=? AND id<>?")->execute([$userId, $sessionId]);
$db->prepare("UPDATE users SET session_locked=1,session_lock_ip=?,
session_lock_session_id=?,session_locked_at=? WHERE id=?")
->execute([$session['ip'], $sessionId, time(), $userId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
return 'User locked to the selected session and IP.';
}
if ($action === 'unlock_user_session') {
$userId = (int)($_POST['user_id'] ?? 0);
$stmt = getDB()->prepare("UPDATE users SET session_locked=0,session_lock_ip=NULL,
session_lock_session_id=NULL,session_locked_at=NULL WHERE id=?");
$stmt->execute([$userId]);
if ($stmt->rowCount() < 1) throw new RuntimeException('User was not found or was already unlocked.');
return 'User session lock removed. Their next login will replace any existing session.';
}
return null;
}
function renderAdminSpamTab(): void {
$db = getDB();
$query = trim((string)($_GET['q'] ?? ''));
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$where = ' WHERE s.last_active>?';
$params = [$cutoff];
if ($query !== '') {
$where .= ' AND (u.username LIKE ? OR s.ip LIKE ?)';
$like = '%' . $query . '%';
$params[] = $like;
$params[] = $like;
}
$sessionStmt = $db->prepare("SELECT s.*,u.username,u.color,u.session_locked,
u.session_lock_ip,u.session_lock_session_id,u.session_locked_at
FROM sessions s JOIN users u ON u.id=s.user_id $where
ORDER BY s.last_active DESC LIMIT 500");
$sessionStmt->execute($params);
$sessions = $sessionStmt->fetchAll();
$lockedStmt = $db->prepare("SELECT u.id,u.username,u.color,u.session_lock_ip,
u.session_lock_session_id,u.session_locked_at,s.last_active
FROM users u LEFT JOIN sessions s ON s.id=u.session_lock_session_id
WHERE u.session_locked=1" . ($query !== '' ? " AND (u.username LIKE ? OR u.session_lock_ip LIKE ?)" : '') . "
ORDER BY u.session_locked_at DESC LIMIT 500");
$lockedStmt->execute($query !== '' ? array_slice($params, 1) : []);
$lockedUsers = $lockedStmt->fetchAll();
?>
<div class="page-head">
<h1>SPAM CONTROL</h1>
<p>// session pinning, login replacement, honeypot, and CAPTCHA controls</p>
</div>
<div class="stat-grid">
<div class="stat-card orange"><div class="stat-num"><?= count($lockedUsers) ?></div><div class="stat-label">Locked Accounts</div></div>
<div class="stat-card blue"><div class="stat-num"><?= count($sessions) ?></div><div class="stat-label">Visible Sessions</div></div>
</div>
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_spam_settings">
<div class="panel">
<div class="panel-head"><span class="panel-title">// SESSION SECURITY</span></div>
<div class="panel-body">
<p class="dim" style="margin-bottom:12px">By default, a successful login silently terminates the user's older session. Locking below pins an account to one session token and IP until an administrator unlocks it.</p>
<label class="cb-row"><input type="checkbox" name="session_ip_binding_enabled"
<?= getConfigVal('chat.session_lock_ip', true) ? 'checked' : '' ?>>
<span>Require every active session to continue using the IP address where it logged in</span></label>
</div>
</div>
<div class="panel">
<div class="panel-head"><span class="panel-title">// REGISTRATION / LOGIN CAPTCHA</span></div>
<div class="panel-body">
<p class="dim" style="margin-bottom:12px">These checks apply only to registration and initial password login. Email 2FA, chat messages, and dashboard actions are not challenged.</p>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_registration_enabled"
<?= getConfigVal('security.captcha.registration_enabled', true) ? 'checked' : '' ?>><span>Protect registration</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_login_enabled"
<?= getConfigVal('security.captcha.login_enabled', true) ? 'checked' : '' ?>><span>Protect login</span></label>
</div>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_honeypot_enabled"
<?= getConfigVal('security.captcha.honeypot.enabled', true) ? 'checked' : '' ?>>
<span>Invisible honeypot fields that deny bot-like submissions</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_internal_enabled"
<?= getConfigVal('security.captcha.internal.enabled', true) ? 'checked' : '' ?>>
<span>Internal arithmetic challenge</span></label>
</div>
<div class="form-row3">
<div class="field"><label>Internal Difficulty</label><select name="captcha_internal_difficulty">
<?php $difficulty = getConfigVal('security.captcha.internal.difficulty', 'easy'); ?>
<option value="easy" <?= $difficulty === 'easy' ? 'selected' : '' ?>>Easy (1-9)</option>
<option value="medium" <?= $difficulty === 'medium' ? 'selected' : '' ?>>Medium (1-25)</option>
</select></div>
<div class="field"><label>Challenge TTL (seconds)</label><input type="number" min="60" max="1800"
name="captcha_internal_ttl" value="<?= (int)getConfigVal('security.captcha.internal.ttl_seconds', 300) ?>"></div>
<div class="field"><label>Maximum Attempts</label><input type="number" min="1" max="10"
name="captcha_internal_attempts" value="<?= (int)getConfigVal('security.captcha.internal.max_attempts', 3) ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="captcha_google_enabled"
<?= getConfigVal('security.captcha.google.enabled', false) ? 'checked' : '' ?>>
<span>Also require Google reCAPTCHA</span></label>
<div class="form-row3">
<div class="field"><label>Google Version</label><select name="captcha_google_version">
<?php $version = getConfigVal('security.captcha.google.version', 'v2'); ?>
<option value="v2" <?= $version === 'v2' ? 'selected' : '' ?>>v2 checkbox</option>
<option value="v3" <?= $version === 'v3' ? 'selected' : '' ?>>v3 score</option>
</select></div>
<div class="field"><label>Site Key</label><input name="captcha_google_site_key"
value="<?= esc(getConfigVal('security.captcha.google.site_key', '')) ?>"></div>
<div class="field"><label>Secret Key</label><input type="password" name="captcha_google_secret_key"
value="<?= esc(getConfigVal('security.captcha.google.secret_key', '')) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>v3 Minimum Score</label><input type="number" min="0" max="1" step="0.1"
name="captcha_google_v3_score" value="<?= esc(getConfigVal('security.captcha.google.v3_min_score', 0.5)) ?>"></div>
<div class="field"><label>Expected Hostname (optional)</label><input name="captcha_google_hostname"
value="<?= esc(getConfigVal('security.captcha.google.hostname', '')) ?>" placeholder="chat.example.com"></div>
</div>
<button class="btn btn-g" type="submit">SAVE SPAM CONTROLS</button>
</div>
</div>
</form>
<form class="search-bar" method="get">
<input type="hidden" name="tab" value="spam">
<input type="text" name="q" value="<?= esc($query) ?>" placeholder="Search username or IP">
<button class="btn btn-b" type="submit">SEARCH</button>
<?php if ($query !== ''): ?><a class="btn btn-t1" href="?tab=spam">CLEAR</a><?php endif; ?>
</form>
<div class="panel">
<div class="panel-head"><span class="panel-title">// ACTIVE SESSION LOCKS</span></div>
<div class="panel-body np">
<?php if (!$sessions): ?>
<div class="empty">NO MATCHING ACTIVE SESSIONS</div>
<?php else: ?>
<div class="tbl-wrap"><table>
<thead><tr><th>User</th><th>IP</th><th>Session</th><th>Last Active</th><th>Lock State</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($sessions as $session):
$isPinned = !empty($session['session_locked'])
&& hash_equals((string)$session['session_lock_session_id'], (string)$session['id']);
?>
<tr>
<td style="color:<?= esc($session['color']) ?>;font-family:var(--mono)"><?= esc($session['username']) ?></td>
<td class="mono"><?= esc($session['ip']) ?></td>
<td class="mono dim"><?= esc(substr((string)$session['id'], 0, 16)) ?>...</td>
<td class="dim"><?= ago((int)$session['last_active']) ?></td>
<td><span class="badge <?= $isPinned ? 'badge-o' : 'badge-g' ?>"><?= $isPinned ? 'LOCKED HERE' : 'REPLACEABLE' ?></span></td>
<td class="actions">
<?php if (!empty($session['session_locked'])): ?>
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="unlock_user_session">
<input type="hidden" name="user_id" value="<?= (int)$session['user_id'] ?>">
<button class="btn btn-sm btn-g" type="submit">UNLOCK</button></form>
<?php else: ?>
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="lock_user_session">
<input type="hidden" name="user_id" value="<?= (int)$session['user_id'] ?>">
<input type="hidden" name="session_id" value="<?= esc($session['id']) ?>">
<button class="btn btn-sm btn-o" type="submit"
onclick="return confirm('Lock this account to the selected session and IP?')">LOCK</button></form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
</div>
</div>
<?php if ($lockedUsers): ?>
<div class="panel">
<div class="panel-head"><span class="panel-title">// ALL LOCKED ACCOUNTS</span></div>
<div class="panel-body np"><div class="tbl-wrap"><table>
<thead><tr><th>User</th><th>Pinned IP</th><th>Pinned Session</th><th>Locked</th><th>Session Status</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($lockedUsers as $user):
$hasActiveSession = !empty($user['last_active']) && (int)$user['last_active'] > $cutoff;
?>
<tr>
<td style="color:<?= esc($user['color']) ?>;font-family:var(--mono)"><?= esc($user['username']) ?></td>
<td class="mono"><?= esc($user['session_lock_ip'] ?: '-') ?></td>
<td class="mono dim"><?= $user['session_lock_session_id'] ? esc(substr((string)$user['session_lock_session_id'], 0, 16)) . '...' : '-' ?></td>
<td class="dim"><?= $user['session_locked_at'] ? fmtTime((int)$user['session_locked_at']) : '-' ?></td>
<td><span class="badge <?= $hasActiveSession ? 'badge-g' : 'badge-r' ?>"><?= $hasActiveSession ? 'ACTIVE' : 'MISSING / EXPIRED' ?></span></td>
<td class="actions"><form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="unlock_user_session">
<input type="hidden" name="user_id" value="<?= (int)$user['id'] ?>">
<button class="btn btn-sm btn-g" type="submit">UNLOCK</button>
</form></td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div></div>
</div>
<?php endif;
}
+162
View File
@@ -0,0 +1,162 @@
<?php
function voiceTranscodingProfile(): string {
$profile = (string)getConfigVal('voice.transcoding.profile', 'm4a_aac');
return in_array($profile, ['m4a_aac', 'mp4_h264_aac'], true) ? $profile : 'm4a_aac';
}
function voiceFfmpegBinary(): string {
$binary = trim((string)getConfigVal('voice.transcoding.ffmpeg_path', 'ffmpeg'));
if ($binary === '' || preg_match('/[\x00-\x1F\x7F]/', $binary)) {
throw new RuntimeException('The FFmpeg binary path is invalid.');
}
return $binary;
}
function runVoiceTranscodeCommand(array $command, int $timeoutSeconds): array {
if (!function_exists('proc_open')) {
return ['exit_code' => 127, 'stderr' => 'PHP proc_open is unavailable.'];
}
$process = @proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes, null, null, ['bypass_shell' => true]);
if (!is_resource($process)) {
return ['exit_code' => 127, 'stderr' => 'FFmpeg could not be started.'];
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$stdout = '';
$stderr = '';
$started = microtime(true);
$exitCode = -1;
while (true) {
$stdout .= (string)stream_get_contents($pipes[1]);
$stderr .= (string)stream_get_contents($pipes[2]);
$status = proc_get_status($process);
if (!$status['running']) {
$exitCode = (int)$status['exitcode'];
break;
}
if ((microtime(true) - $started) >= $timeoutSeconds) {
proc_terminate($process);
usleep(100000);
$status = proc_get_status($process);
if ($status['running']) proc_terminate($process, 9);
$exitCode = 124;
$stderr .= "\nFFmpeg timed out.";
break;
}
usleep(50000);
}
$stdout .= (string)stream_get_contents($pipes[1]);
$stderr .= (string)stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$closeCode = proc_close($process);
if ($exitCode < 0) $exitCode = $closeCode;
return [
'exit_code' => $exitCode,
'stdout' => substr($stdout, -4000),
'stderr' => substr($stderr, -4000),
];
}
function transcodeVoiceRecording(
string $sourcePath,
string $sourceFilename,
string $sourceMime,
?int $bitrateOverride = null
): array {
if (!getConfigVal('voice.transcoding.enabled', false)) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
$profile = voiceTranscodingProfile();
$bitrate = max(48, min(320, $bitrateOverride
?? (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128)));
$timeout = max(5, min(300, (int)getConfigVal('voice.transcoding.timeout_seconds', 45)));
$fallback = (bool)getConfigVal('voice.transcoding.fallback_to_source', true);
$token = pathinfo($sourceFilename, PATHINFO_FILENAME);
if (!preg_match('/^[a-f0-9]{40}$/', $token)) {
throw new RuntimeException('Voice recording filename is invalid.');
}
$extension = $profile === 'mp4_h264_aac' ? 'mp4' : 'm4a';
$outputMime = $profile === 'mp4_h264_aac' ? 'video/mp4' : 'audio/mp4';
$outputFilename = $token . '.' . $extension;
$outputPath = dirname($sourcePath) . '/' . $outputFilename;
$temporaryPath = dirname($sourcePath) . '/.' . $token . '.tmp.' . $extension;
$binary = voiceFfmpegBinary();
if ($profile === 'mp4_h264_aac') {
$command = [
$binary, '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
'-f', 'lavfi', '-i', 'color=c=black:s=16x16:r=1',
'-i', $sourcePath,
'-map', '0:v:0', '-map', '1:a:0', '-shortest',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'stillimage',
'-profile:v', 'baseline', '-level', '3.0', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-profile:a', 'aac_low', '-b:a', $bitrate . 'k',
'-map_metadata', '-1', '-movflags', '+faststart', $temporaryPath,
];
} else {
$command = [
$binary, '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
'-i', $sourcePath, '-vn',
'-c:a', 'aac', '-profile:a', 'aac_low', '-b:a', $bitrate . 'k',
'-map_metadata', '-1', '-movflags', '+faststart', $temporaryPath,
];
}
$result = runVoiceTranscodeCommand($command, $timeout);
$validOutput = $result['exit_code'] === 0
&& is_file($temporaryPath)
&& filesize($temporaryPath) > 0;
if (!$validOutput) {
if (is_file($temporaryPath)) @unlink($temporaryPath);
$detail = trim((string)($result['stderr'] ?? ''));
error_log('Voice transcoding failed: ' . ($detail !== '' ? $detail : 'unknown FFmpeg error'));
if ($fallback) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
throw new RuntimeException('Voice transcoding failed. Check the FFmpeg dashboard settings.');
}
$sourceBackupPath = null;
if ($outputPath === $sourcePath) {
$sourceBackupPath = dirname($sourcePath) . '/.' . $token . '.source.' . $extension;
if (!@rename($sourcePath, $sourceBackupPath)) {
@unlink($temporaryPath);
if ($fallback) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
throw new RuntimeException('The original voice clip could not be prepared for replacement.');
}
}
if (!@rename($temporaryPath, $outputPath)) {
@unlink($temporaryPath);
if ($sourceBackupPath !== null && is_file($sourceBackupPath)) {
@rename($sourceBackupPath, $sourcePath);
}
if ($fallback && is_file($sourcePath)) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
throw new RuntimeException('The transcoded voice clip could not be stored.');
}
if ($sourceBackupPath !== null) {
@unlink($sourceBackupPath);
} else {
@unlink($sourcePath);
}
return ['filename' => $outputFilename, 'mime' => $outputMime, 'transcoded' => true];
}
+26
View File
@@ -27,12 +27,38 @@ server {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
location ~* \.html?$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# Never execute files from the voice upload directory # Never execute files from the voice upload directory
location ~ ^/uploads/voice/.*\.(php|phtml|phar|cgi|pl|py|sh)$ { location ~ ^/uploads/voice/.*\.(php|phtml|phar|cgi|pl|py|sh)$ {
deny all; deny all;
return 404; return 404;
} }
location ~* ^/uploads/voice/.*\.m4a$ {
default_type audio/mp4;
try_files $uri =404;
}
location ~* ^/uploads/voice/.*\.aac$ {
default_type audio/aac;
try_files $uri =404;
}
location ~* ^/uploads/voice/.*\.webm$ {
default_type audio/webm;
try_files $uri =404;
}
location ~* ^/uploads/voice/.*\.mp4$ {
default_type video/mp4;
try_files $uri =404;
}
# PHP files # PHP files
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.1-fpm.sock; # adjust PHP version fastcgi_pass unix:/run/php/php8.1-fpm.sock; # adjust PHP version