Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ vendor/
# Node.js
#-------------------------
node_modules/
public/dist/
public/dist/
public/sw.js
public/workbox-*.js

#-------------------------
# IDE / Development Files
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ RUN corepack enable && corepack prepare pnpm@latest --activate \
# Copy source files needed for the build
COPY vite.config.js ./
COPY src/ ./src/
COPY public/ ./public/

# Build production assets
RUN pnpm build
Expand Down Expand Up @@ -147,6 +148,7 @@ COPY --from=composer /app/vendor ./vendor

# Copy built frontend assets from the node stage
COPY --from=node /app/public/dist ./public/dist
COPY --from=node /app/public/sw.js ./public/sw.js

# Create writable directories for CodeIgniter
# These directories need write permissions for logs, cache, sessions, etc.
Expand Down
2 changes: 2 additions & 0 deletions app/Config/Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
$routes->get('messages', 'MessagesController::list', ['filter' => ['apiFormat', 'auth']]);
$routes->post('messages', 'MessagesController::create', ['filter' => ['apiFormat', 'auth', 'rate:write', 'validate:message']]);
$routes->get('messages/xml', 'MessagesController::listXml', ['filter' => 'auth']);
$routes->post('push-subscriptions', 'PushSubscriptionsController::create', ['filter' => ['apiFormat', 'auth', 'rate:write']]);
$routes->delete('push-subscriptions', 'PushSubscriptionsController::delete', ['filter' => ['apiFormat', 'auth', 'rate:write']]);
});

// Compatibility shims: same v1 implementation, with machine-readable sunset metadata.
Expand Down
13 changes: 13 additions & 0 deletions app/Config/Services.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
use App\Contracts\ChatFormatter as ChatFormatterContract;
use App\Contracts\ChatRepository;
use App\Contracts\CspReportRepository;
use App\Contracts\PushSubscriptionRepository;
use App\Contracts\UserRepository;
use App\Controllers\Api\V1\MessagesController;
use App\Controllers\Api\V1\PushSubscriptionsController;
use App\Controllers\AuditLog;
use App\Controllers\Auth;
use App\Controllers\Chat;
Expand All @@ -19,6 +21,7 @@
use App\Models\AuditLogModel;
use App\Models\ChatModel;
use App\Models\CspReportModel;
use App\Models\PushSubscriptionModel;
use App\Models\UserModel;
use App\Services\AuditLogger;
use App\Services\ChatFormatter;
Expand Down Expand Up @@ -64,6 +67,7 @@ public static function controller(string $controllerClass): Controller
Home::class => new Home(),
Chat::class => new Chat(static::chatRepository(), static::chatFormatter()),
MessagesController::class => new MessagesController(static::chatRepository(), static::chatFormatter()),
PushSubscriptionsController::class => new PushSubscriptionsController(static::pushSubscriptionRepository()),
Auth::class => new Auth(static::userRepository(), static::auditLogger()),
CspReport::class => new CspReport(static::cspReportRepository()),
AuditLog::class => new AuditLog(static::auditLogRepository()),
Expand Down Expand Up @@ -107,6 +111,15 @@ public static function cspReportRepository(bool $getShared = true): CspReportRep
return new CspReportModel();
}

public static function pushSubscriptionRepository(bool $getShared = true): PushSubscriptionRepository
{
if ($getShared) {
return static::getSharedInstance('pushSubscriptionRepository');
}

return new PushSubscriptionModel();
}

public static function auditLogRepository(bool $getShared = true): AuditLogRepository
{
if ($getShared) {
Expand Down
11 changes: 11 additions & 0 deletions app/Contracts/PushSubscriptionRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Contracts;

interface PushSubscriptionRepository
{
/** @param array{endpoint: string, endpoint_hash: string, p256dh: string, auth: string, content_encoding: string} $subscription */
public function saveForUser(int $userId, array $subscription): bool;

public function deleteForUser(int $userId, string $endpointHash): bool;
}
81 changes: 81 additions & 0 deletions app/Controllers/Api/V1/PushSubscriptionsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace App\Controllers\Api\V1;

use App\Contracts\PushSubscriptionRepository;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;

final class PushSubscriptionsController extends BaseController
{
public function __construct(private readonly PushSubscriptionRepository $subscriptions)
{
}

public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true);
$subscription = $this->normalize($payload);
if ($subscription === null) {
return $this->respondWithJson([
'error' => ['type' => 'validation', 'message' => 'A valid HTTPS push subscription is required.'],
], 422);
}

$saved = $this->subscriptions->saveForUser((int) $this->session->get('user_id'), $subscription);
if (! $saved) {
return $this->respondWithJson([
'error' => ['type' => 'storage', 'message' => 'The push subscription could not be saved.'],
], 500);
}

return $this->respondWithJson(['success' => true], 201);
}

public function delete(): ResponseInterface
{
$payload = $this->request->getJSON(true);
$endpoint = is_array($payload) ? trim((string) ($payload['endpoint'] ?? '')) : '';
if (! $this->validEndpoint($endpoint)) {
return $this->respondWithJson([
'error' => ['type' => 'validation', 'message' => 'A valid HTTPS endpoint is required.'],
], 422);
}

$this->subscriptions->deleteForUser((int) $this->session->get('user_id'), hash('sha256', $endpoint));

return $this->response->setStatusCode(204);
}

/** @return array{endpoint: string, endpoint_hash: string, p256dh: string, auth: string, content_encoding: string}|null */
private function normalize(mixed $payload): ?array
{
if (! is_array($payload)) {
return null;
}

$endpoint = trim((string) ($payload['endpoint'] ?? ''));
$keys = is_array($payload['keys'] ?? null) ? $payload['keys'] : [];
$p256dh = trim((string) ($keys['p256dh'] ?? ''));
$auth = trim((string) ($keys['auth'] ?? ''));

if (! $this->validEndpoint($endpoint) || $p256dh === '' || $auth === '' || strlen($p256dh) > 255 || strlen($auth) > 255) {
return null;
}

return [
'endpoint' => $endpoint,
'endpoint_hash' => hash('sha256', $endpoint),
'p256dh' => $p256dh,
'auth' => $auth,
'content_encoding' => 'aes128gcm',
];
}

private function validEndpoint(string $endpoint): bool
{
return strlen($endpoint) <= 2048
&& filter_var($endpoint, FILTER_VALIDATE_URL) !== false
&& parse_url($endpoint, PHP_URL_SCHEME) === 'https';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

final class CreatePushSubscriptionsTable extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'user_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'endpoint' => ['type' => 'TEXT'],
'endpoint_hash' => ['type' => 'CHAR', 'constraint' => 64],
'p256dh' => ['type' => 'VARCHAR', 'constraint' => 255],
'auth' => ['type' => 'VARCHAR', 'constraint' => 255],
'content_encoding' => ['type' => 'VARCHAR', 'constraint' => 32, 'default' => 'aes128gcm'],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('user_id');
$this->forge->addUniqueKey('endpoint_hash');
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('push_subscriptions');
}

public function down(): void
{
$this->forge->dropTable('push_subscriptions');
}
}
31 changes: 31 additions & 0 deletions app/Models/PushSubscriptionModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Models;

use App\Contracts\PushSubscriptionRepository;
use CodeIgniter\Model;

final class PushSubscriptionModel extends Model implements PushSubscriptionRepository
{
protected $table = 'push_subscriptions';
protected $primaryKey = 'id';
protected $useTimestamps = true;
protected $allowedFields = ['user_id', 'endpoint', 'endpoint_hash', 'p256dh', 'auth', 'content_encoding'];

public function saveForUser(int $userId, array $subscription): bool
{
$existing = $this->select('id')->where('endpoint_hash', $subscription['endpoint_hash'])->first();
$row = ['user_id' => $userId, ...$subscription];

if (is_array($existing)) {
return $this->update($existing['id'], $row);
}

return $this->insert($row) !== false;
}

public function deleteForUser(int $userId, string $endpointHash): bool
{
return $this->where('user_id', $userId)->where('endpoint_hash', $endpointHash)->delete();
}
}
8 changes: 7 additions & 1 deletion app/Views/chat/svelteView.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<html lang="en">
<head>
<title>CodeIgniter Chat - Svelte Edition</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#3f6398">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
<meta name="csrf-token" content="<?= esc(csrf_hash()) ?>">
<?php
/**
Expand Down Expand Up @@ -71,7 +75,8 @@
// POST endpoint for sending new messages
update: "<?= esc(site_url('api/v1/messages')) ?>",
// GET endpoint for fetching messages (JSON format)
messagesApi: "<?= esc(site_url('api/v1/messages')) ?>"
messagesApi: "<?= esc(site_url('api/v1/messages')) ?>",
pushSubscriptions: "<?= esc(site_url('api/v1/push-subscriptions')) ?>"
};

// CSRF protection token name (CodeIgniter's built-in XSS protection)
Expand Down Expand Up @@ -101,6 +106,7 @@
* @see App\Libraries\ChatWebSocketServer for server-side validation
*/
window.WEBSOCKET_TOKEN = "<?= esc(session()->get('websocket_token') ?? '') ?>";
window.PUSH_PUBLIC_KEY = "<?= esc((string) env('push.vapidPublicKey', '')) ?>";
</script>

<!--
Expand Down
8 changes: 7 additions & 1 deletion app/Views/chat/vueView.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<html lang="en">
<head>
<title>CodeIgniter Chat - Vue.js Edition</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#3f6398">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
<meta name="csrf-token" content="<?= esc(csrf_hash()) ?>">
<?php
// Use the Vite helper to load assets
Expand All @@ -28,7 +32,8 @@
// API routes for chat operations
window.CHAT_ROUTES = {
update: "<?= esc(site_url('api/v1/messages')) ?>",
messagesApi: "<?= esc(site_url('api/v1/messages')) ?>"
messagesApi: "<?= esc(site_url('api/v1/messages')) ?>",
pushSubscriptions: "<?= esc(site_url('api/v1/push-subscriptions')) ?>"
};

// CSRF protection token name (CodeIgniter's built-in XSS protection)
Expand All @@ -53,6 +58,7 @@
* @see App\Libraries\ChatWebSocketServer for server-side validation
*/
window.WEBSOCKET_TOKEN = "<?= esc(session()->get('websocket_token') ?? '') ?>";
window.PUSH_PUBLIC_KEY = "<?= esc((string) env('push.vapidPublicKey', '')) ?>";
</script>
<!-- Load assets built with Vite -->
<?= vite_tags('src/vue/main.js') ?>
Expand Down
49 changes: 49 additions & 0 deletions docs/pwa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Progressive Web App support

The Vue (`/chat/vue`) and Svelte (`/chat/svelte`) clients share one installable PWA. Vite emits `/manifest.webmanifest` and `/sw.js`; Workbox precaches only the public application shell (versioned JavaScript/CSS, icons, and the offline page).

## Build

```sh
corepack pnpm install --frozen-lockfile
corepack pnpm pwa:assets # only needed after changing public/pwa-icon.svg
corepack pnpm build
```

The normal build generates the manifest and root-scoped service worker. Serve the `public/` directory over HTTPS (localhost is also accepted by browsers) and do not move `sw.js` under `/dist`, because that would narrow its scope.

## Offline behavior

- Navigation uses the cached public offline page when the network is unavailable. Authenticated PHP pages are deliberately not cached.
- `GET /api/v1/messages` is network-first with a three-second timeout and a one-day maximum cache age. Clients add `pwa_user=<id>` to the cache key so cached conversations are not mixed between users.
- `POST /api/v1/messages` uses Workbox Background Sync and remains queued for up to 24 hours after a network failure. Both clients retain queued state until the service worker confirms replay; permanent authentication or validation failures are shown as failed delivery.
- Logging out clears cached message responses and the local background-sync database.

Queued writes retain the authenticated session and CSRF token captured when they were created. A replay rejected because the session or token expired is not silently represented as delivered; reopening the app refreshes from the server.

## Web Push subscriptions

Set the public VAPID key in the environment:

```dotenv
push.vapidPublicKey=YOUR_URL_SAFE_BASE64_PUBLIC_KEY
```

When configured, supported browsers show an **Enable notifications** action. The client creates a browser `PushSubscription` and sends it to authenticated endpoint `POST /api/v1/push-subscriptions`. `DELETE /api/v1/push-subscriptions` removes the current user's endpoint. The migration stores endpoint hashes, encryption keys, and ownership; sending push payloads and managing the private VAPID key belong in the notification delivery service.

Run migrations after deployment:

```sh
php spark migrate
```

## Verification

1. Run `corepack pnpm build` and confirm `public/manifest.webmanifest` and `public/sw.js` exist.
2. Serve the app over HTTPS, authenticate, and visit both framework clients.
3. In browser application tools, confirm the manifest is installable and the service worker controls the page.
4. Load messages once, switch the browser network to Offline, and confirm the offline indicator and cached messages appear.
5. Post while offline, restore the network, and confirm the queued notice clears only after the service worker reports a successful replay.
6. Run current Lighthouse best-practices and performance audits against a production build. Lighthouse 12 removed the scored PWA category, so installability is verified through browser application tools plus the automated manifest and offline-browser tests instead of an obsolete PWA score.

Mutation testing remains manual-only through the `Mutation Tests` workflow's `workflow_dispatch` trigger; PWA changes do not make it a pull-request check.
18 changes: 18 additions & 0 deletions e2e/pwa.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, test } from '@playwright/test';

test('manifest provides the required installability metadata and icons', async ({ request }) => {
const response = await request.get('/manifest.webmanifest');
expect(response.ok()).toBeTruthy();

const manifest = await response.json();
expect(manifest.name).toBe('CodeIgniter Chat');
expect(manifest.display).toBe('standalone');
expect(manifest.start_url).toBe('/chat/vue');
expect(manifest.icons).toEqual(
expect.arrayContaining([
expect.objectContaining({ sizes: '192x192', purpose: 'any' }),
expect.objectContaining({ sizes: '512x512', purpose: 'any' }),
expect.objectContaining({ sizes: '512x512', purpose: 'maskable' }),
]),
);
});
Loading
Loading