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
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Tests

on:
push:
branches: ["**"]
pull_request:

jobs:
test:
runs-on: ubuntu-latest

name: PHP 8.4

steps:
- uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: pdo, pdo_sqlite
coverage: xdebug

- name: Install dependencies
run: composer update --no-interaction --prefer-dist

- name: Run tests
run: composer test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
.idea
vendor
.phpunit.cache
clover.xml
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is `xibosignage/support`, a PHP utility library that provides reusable support classes for the Xibo Digital Signage Platform. It covers input sanitization, database abstraction, CSRF/nonce management, logging integrations, and exception handling with HTTP-aware responses.

## Common Commands

```bash
# Install dependencies
composer install

# Run tests
composer test

# Run tests with coverage report (requires Xdebug)
composer test:coverage

# Run code style checks (PSR-2 based, with Xibo customisations)
composer lint
```

## Architecture

### Module Breakdown (`src/Xibo/Support/`)

**Database/**
`PdoStorageService` implements `StorageServiceInterface` providing a PDO/MySQL wrapper with named connection pooling, transaction management, and deadlock retry logic (max 2 retries via `DeadLockException`).

**Exception/**
Seventeen custom exceptions all extend `GeneralException`. Each maps to an HTTP status (401, 403, 404, etc.) and can render a JSON HTTP response via `generateHttpResponse(ResponseInterface $response)`. This is the primary error-propagation mechanism across the Xibo platform.

**Nonce/**
CSRF protection via bcrypt-hashed nonces stored through `StorageServiceInterface`. `CsrfMiddleware` is a PSR-7 middleware that validates nonces on incoming requests. `Nonce` entities support expiration, metadata, and JSON serialisation.

**Sanitizer/**
`RespectSanitizer` wraps Respect\Validation and Symfony HtmlSanitizer to provide typed, validated input access (`getInt`, `getString`, `getDate`, `getHtml`, etc.). Callers pass an associative array of raw input; the sanitizer enforces types and optionally throws on validation failure.

**Validator/**
Thin wrapper (`RespectValidator`) around Respect\Validation for standalone rule checks separate from sanitization.

**Monolog/**
Two Monolog integrations: `RocketChatHandler` (webhook log posting) and `ProxyIpProcessor` (real-IP extraction from proxy headers).

### Coding Standards

The custom PHPCS ruleset at `src/Standards/xibo_ruleset.xml` extends PSR-2 with:
- Double-quoted strings enforced (Squiz rule)
- Forbidden functions: `delete`, `print`, `create_function`
- Line length checks ignore comments
- `ElseIfDeclaration` rule excluded

PHP platform target is **8.1+**; CI runs against **8.4**.

### Test Suite

Tests live under `tests/` and mirror the `src/Xibo/Support/` module structure. Key notes:

- `tests/Database/PdoStorageServiceSqliteTest.php` uses SQLite in-memory via a `connect()` override — no MySQL needed for the happy-path tests.
- `tests/Database/PdoStorageServiceMockTest.php` mocks PDO to exercise reconnect (error 2006) and deadlock retry (errors 1213/1205) paths.
- `tests/Nonce/CsrfMiddlewareTest.php` saves and restores `$_SESSION` in setUp/tearDown rather than using process isolation.
- `RespectSanitizer::getString` uses `strip_tags` (removes tags, does not encode entities). `getHtml` uses Symfony HtmlSanitizer (preserves safe tags). These behave differently and have separate tests.
- `getCheckbox` never throws and uses loose `!= null` comparison for the `default` option — see test comments.
- `getDate` uses loose `== null` for the missing-value check (so empty string is treated as missing), unlike the other getters which use strict `===`.
99 changes: 99 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Xibo Support Library

A PHP utility library providing foundational support classes for the [Xibo Digital Signage Platform](https://xibosignage.com). Consumed by Xibo CMS and related services as a Composer package.

## Modules

### Sanitizer
Type-safe, validated input access. Pass an associative array of raw input (e.g. from a HTTP request) and retrieve values as the expected type:

```php
$san = (new RespectSanitizer())->setCollection($request->getParams());

$id = $san->getInt('id');
$name = $san->getString('name');
$body = $san->getHtml('body'); // Symfony HtmlSanitizer — preserves safe tags
$enabled = $san->getCheckbox('active');
$date = $san->getDate('from', ['dateFormat' => 'Y-m-d']);
```

All getters accept an `$options` array for defaults, custom Respect\Validation rules, and configurable exception throwing (`throw`, `throwClass`, `throwMessage`).

**Note:** `getString` uses `strip_tags` (removes all tags, does not encode entities). `getHtml` uses Symfony HtmlSanitizer and is the correct choice when HTML content must be preserved safely.

### Validator
Standalone boolean validation using Respect\Validation, separate from sanitization:

```php
$v = new RespectValidator();
$v->int('42'); // true
$v->double('3.14'); // true
$v->string('hello', ['Length' => [1, 100]]); // true
```

### Exception
Seventeen domain exceptions extending `GeneralException`, each with a fixed HTTP status code and a `generateHttpResponse(ResponseInterface)` method that writes a JSON error body:

| Exception | Status |
|-----------|--------|
| `AuthenticationRequiredException` | 401 |
| `AccessDeniedException` | 403 |
| `NotFoundException` | 404 |
| `DuplicateEntityException` | 409 |
| `InvalidArgumentException` | 422 |
| Everything else | 500 |

### Nonce
CSRF protection built on bcrypt-hashed nonces stored via `StorageServiceInterface`:

```php
// Create and persist
$nonce = $nonceService->create($entityId, 'upload', 300);
$nonceService->persist($nonce);
$token = $nonce->getCompleteNonce(); // "plaintextNonce:::lookup"

// Verify later
$verified = $nonceService->getSplitVerified($token, 'upload');
```

`CsrfMiddleware` is a PSR-7 middleware that validates `X-XSRF-TOKEN` headers (or a body parameter) on POST/PUT/DELETE requests against a session-stored token.

### Database
`PdoStorageService` is a PDO/MySQL wrapper with named connection pooling, automatic transaction management on writes, reconnect handling (MySQL error 2006), and deadlock retry logic (errors 1213/1205, max 2 retries):

```php
$db->insert('INSERT INTO t (name) VALUES (:name)', [':name' => 'x']);
$db->commitIfNecessary();

$rows = $db->select('SELECT * FROM t WHERE id = :id', [':id' => 1]);

// Deadlock-safe write with automatic retry
$db->updateWithDeadlockLoop('UPDATE t SET val = :v WHERE id = :id', [...]);
```

### Monolog
- **`RocketChatHandler`** — posts log records to a Rocket.Chat inbound webhook. Colour-coded by level (red ≥ ERROR, yellow = WARNING, green ≥ INFO, grey = DEBUG).
- **`ProxyIpProcessor`** — adds the real client IP to each log record by inspecting `X_FORWARDED_FOR`, `HTTP_X_FORWARDED_FOR`, `CLIENT_IP`, and `REMOTE_ADDR` in that order.

## Installation

```bash
composer require xibosignage/support
```

Optional dependencies (required for specific modules):

```bash
composer require nesbot/carbon # RespectSanitizer::getDate()
composer require respect/validation # RespectSanitizer and RespectValidator
composer require monolog/monolog # RocketChatHandler and ProxyIpProcessor
```

## Development

```bash
composer install
composer test # run PHPUnit test suite
composer test:coverage # run with Clover coverage report (requires Xdebug)
composer lint # PHPCS code style check
```
15 changes: 14 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@
"autoload": {
"psr-4": { "Xibo\\" : "src/Xibo" }
},
"autoload-dev": {
"psr-4": { "Xibo\\Support\\Tests\\": "tests/" }
},
"require-dev": {
"respect/validation": "2.2.*",
"squizlabs/php_codesniffer": "3.*",
"exussum12/coverage-checker": "^0.11.2"
"exussum12/coverage-checker": "^0.11.2",
"phpunit/phpunit": "^10.5",
"monolog/monolog": "^2.9",
"guzzlehttp/guzzle": "^7.8",
"guzzlehttp/psr7": "^2.6",
"nesbot/carbon": "^2.72"
},
"scripts": {
"lint": "phpcs --standard=src/Standards/xibo_ruleset.xml src/",
"test": "phpunit",
"test:coverage": "phpunit --coverage-clover clover.xml --coverage-text"
},
"config": {
"platform": {
Expand Down
30 changes: 30 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<coverage includeUncoveredFiles="true">
<report>
<clover outputFile="clover.xml"/>
<text outputFile="php://stdout" showOnlySummary="true"/>
</report>
</coverage>
<php>
<ini name="error_reporting" value="-1"/>
<ini name="date.timezone" value="UTC"/>
</php>
</phpunit>
2 changes: 1 addition & 1 deletion src/Xibo/Support/Exception/InvalidNonceException.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

class InvalidNonceException extends GeneralException
{
public function __construct($message = "Token Expired", $code = 0, \Throwable $previous = null)
public function __construct($message = "Token Expired", $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Xibo/Support/Monolog/Handler/RocketChatHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function __construct($url, $client = null, $level = Logger::CRITICAL, $bu
}

/** @inheritdoc */
protected function write(array $record)
protected function write(array $record): void
{
$formattedMessage = sprintf(
"Log channel: *%s*\nLog level: *%s*\n```%s```",
Expand Down
4 changes: 2 additions & 2 deletions src/Xibo/Support/Nonce/NonceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public final function getVerified($nonce, $lookup, $action)
}

// Verify the action
if (!$verifyNonce->action === $action) {
if ($verifyNonce->action !== $action) {
throw new InvalidNonceException();
}

Expand All @@ -67,7 +67,7 @@ public final function getVerified($nonce, $lookup, $action)
/** @inheritDoc */
public final function getSplitVerified($nonce, $action, $delimiter = ':::')
{
$parts = explode(':::', $nonce);
$parts = explode($delimiter, $nonce);
return $this->getVerified($parts[0], $parts[1], $action);
}
}
Loading
Loading