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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.39
0.0.40
5 changes: 5 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# Changelog
SPIN Framework Changelog

## 0.0.40
- **Feature:** Add domain exception classes: `CacheException`, `ConfigException`, `DatabaseException`, `MiddlewareException` — all extending `SpinException`
- **Feature:** Wire `CacheException` into cache adapters (APCu, Redis), `ConfigException` into `Config`, `DatabaseException` into `PdoConnection` and `ConnectionManager`
- **Fix:** `ConnectionManager` password-sanitization now throws `DatabaseException` without `$previous` on tainted traces, preventing credential leakage

## 0.0.39
- **Feature:** `.env` file auto-loading — `DotEnv::load()` reads `.env` from the project root at startup, populating environment variables before config macro expansion and `env()` calls; real process env vars always take precedence
- **Fix:** `${env:VAR:default}` inline default syntax in config macros now works as documented; missing variables now resolve to the specified default instead of silently dropping to empty string
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "celarius/spin-framework",
"description": "A super lightweight PHP UI/REST Framework",
"version": "0.0.39",
"version": "0.0.40",
"keywords": [
"php8",
"php-framework",
Expand Down
239 changes: 221 additions & 18 deletions doc/User-Guide/Helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
* [2.2. Cipher](#22-cipher)
* [2.3. Hash](#23-hash)
* [2.4. UUID](#24-uuid)
* [2.5. JWT](#25-jwt)
* [2.6. EWT](#26-ewt)

<!-- /MarkdownTOC -->

Expand Down Expand Up @@ -306,42 +308,243 @@ return responseFile($filename);
---
# 2. Global Helper Objects

Helper classes live under the `Spin\Helpers` namespace. All methods are static unless noted otherwise.

## 2.1. ArrayToXml
The `ArrayToXml()` converts an `$array` to a `$xml` document.

Converts a PHP associative array to an XML document string.

```php
$array = [];
$array['key'] = 'value';
# Convert array to XML document
$xml = (new \Spin\Helpers\ArrayToXml())->buildXML($array);
public function __construct(string $xmlVersion = '1.0', string $xmlEncoding = 'UTF-8')
public function buildXML(array $data, string $startElement = 'data'): string|false
```

Returns the XML as a Base64-encoded string on success, `false` on failure.

**Special key prefixes** control how array keys are mapped to XML:

| Prefix | Effect | Example key |
|--------|--------|-------------|
| `@` | XML attribute on the parent element | `@id` |
| `%` | Text content of the parent element | `%` |
| `#` | CDATA section | `#note` |
| `!` | CDATA section (alternative) | `!note` |

```php
# Basic usage
$xml = (new \Spin\Helpers\ArrayToXml())->buildXML([
'name' => 'Alice',
'age' => 30,
]);

# With XML attributes and CDATA
$xml = (new \Spin\Helpers\ArrayToXml())->buildXML([
'@id' => 42, // becomes <data id="42">
'name' => 'Alice',
'bio' => [
'#content' => 'Contains <special> & chars', // wrapped in CDATA
],
], 'user');
```

---

## 2.2. Cipher
The `Cipher` helper encrypts/decrypts strings using OpenSSL.

Encrypts and decrypts strings using OpenSSL. All methods are static.

```php
public static function encrypt(string $data, string $secret = '', string $algorithm = 'AES-256-CBC'): string
public static function decrypt(string $data, string $secret = '', string $algorithm = 'AES-256-CBC'): bool|string
public static function encryptEx(string $data, string $secret, string $cipher = 'aes-256-ctr', string $hashAlgo = 'sha3-512'): string
public static function decryptEx(string $input, string $secret): mixed
public static function getMethods(): array
```

`encrypt` / `decrypt` use a random IV and return Base64-encoded ciphertext.

`encryptEx` / `decryptEx` add an HMAC signature. The token format is:
```
cipher[hashAlgo]:base64(iv).base64(encrypted).base64(hmac)
```
`decryptEx` throws an `\Exception` if the HMAC does not verify.

```php
# Encrypt a value
$encryptedValue = \Spin\Helpers\Cipher::encrypt( $plain, 'secret', 'AES-256-CBC' );
# Basic encrypt / decrypt
$secret = env('APP_SECRET');
$encrypted = \Spin\Helpers\Cipher::encrypt('sensitive data', $secret);
$plain = \Spin\Helpers\Cipher::decrypt($encrypted, $secret);

# Extended encrypt / decrypt (with HMAC verification)
$token = \Spin\Helpers\Cipher::encryptEx('sensitive data', $secret);
try {
$plain = \Spin\Helpers\Cipher::decryptEx($token, $secret);
} catch (\Exception $e) {
// tampered or wrong secret
}

# Decrypt a value
$plain = \Spin\Helpers\Cipher::decrypt( $encryptedValue, 'secret', 'AES-256-CBC' );
# List available OpenSSL cipher methods
$methods = \Spin\Helpers\Cipher::getMethods();
```

---

## 2.3. Hash
The `Hash` helper produces hashes using OpenSSL digest methods.

Generates and verifies cryptographic digests using OpenSSL. All methods are static.

```php
public static function generate(string $data, string $method = 'SHA256'): string
public static function check(string $data, string $hash, string $method = 'SHA256'): bool
public static function getMethods(): array
```

`check` uses a timing-safe string comparison to prevent timing attacks.

```php
# Produce a hash
$digest = \Spin\Helpers\Hash::generate('This is the data','SHA256');
# Generate a hash
$hash = \Spin\Helpers\Hash::generate('my data');

# Verify a hash
if (\Spin\Helpers\Hash::check('my data', $hash)) {
// data is unchanged
}

# List available OpenSSL digest methods
$methods = \Spin\Helpers\Hash::getMethods();
```

---

## 2.4. UUID
The `UUID` helper produces UUID v3, v4 and v5 unique UUID's.

Generates and validates UUIDs using the Ramsey UUID library. All methods are static.

```php
public static function generate(): string // v7 — default
public static function v3(string $namespace, string $name): string
public static function v4(): string
public static function v5(string $namespace, string $name): string
public static function v6(): string
public static function v7(): string
public static function is_valid(string $uuid): bool
```

| Version | Algorithm | Use case |
|---------|-----------|----------|
| v3 | Namespace + MD5 | Deterministic from a name |
| v4 | Random | General-purpose unique IDs |
| v5 | Namespace + SHA1 | Deterministic, collision-resistant |
| v6 | Time-based (legacy) | Ordered, backwards-compatible with v1 |
| v7 | Time-based (recommended) | Ordered, sortable, database-friendly |

`generate()` returns a v7 UUID. Prefer v7 for database primary keys (monotonically increasing, index-friendly). Use v4 when ordering is irrelevant.

`is_valid` validates UUIDs of versions v3, v4, and v5.

```php
# Default (v7 — time-ordered, ideal for DB primary keys)
$id = \Spin\Helpers\UUID::generate();

# Random v4
$id = \Spin\Helpers\UUID::v4();

# Deterministic v5 (same namespace + name always yields the same UUID)
$ns = \Spin\Helpers\UUID::v4(); // generate a namespace once, store it
$id = \Spin\Helpers\UUID::v5($ns, 'user@example.com');

# Validate
if (\Spin\Helpers\UUID::is_valid($id)) {
// valid UUID
}
```

---

## 2.5. JWT

Signs and verifies JSON Web Tokens via the `firebase/php-jwt` library. All methods are static.

```php
public static function encode(array $payload, string $key, string $alg, ?string $keyId = null, ?array $head = null): string
public static function decode(string $jwt, string $key, string $algo = 'HS256'): array
public static function sign(string $msg, string $key, string $alg): string
```

**Supported algorithms:** `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`

**Standard payload claims:**

| Claim | Type | Description |
|-------|------|-------------|
| `sub` | string | Subject (e.g. user ID) |
| `iat` | int | Issued-at timestamp |
| `exp` | int | Expiry timestamp |
| `nbf` | int | Not-before timestamp |

```php
# UUIDv4 GUID
$uuidv4 = \Spin\Helpers\UUID::generate();
$secret = env('APP_SECRET');

# UUIDv5 GUID
$uuidv5 = \Spin\Helpers\UUID::v5($uuidv4,'My v5 UUID');
# Create a token
$token = \Spin\Helpers\JWT::encode([
'sub' => (string) $userId,
'iat' => time(),
'exp' => time() + 3600,
], $secret, 'HS256');

# Decode and verify
try {
$payload = \Spin\Helpers\JWT::decode($token, $secret, 'HS256');
$userId = $payload['sub'];
} catch (\Exception $e) {
// invalid signature, expired, or malformed token
}

# Middleware pattern — extract Bearer token and validate
$authHeader = getRequest()->getHeaderLine('Authorization');
if (str_starts_with($authHeader, 'Bearer ')) {
$token = substr($authHeader, 7);
$payload = \Spin\Helpers\JWT::decode($token, $secret, 'HS256');
container('jwt_claims', $payload);
}
```

---

## 2.6. EWT

EWT (Encrypted Web Token) is a custom token format where the payload is **encrypted** (not just signed). Use EWT instead of JWT when the payload contains sensitive data that must not be readable by the client.

Unlike JWT (which only signs), EWT encrypts the payload with AES and includes an HMAC signature. `decode` returns `null` if signature verification fails rather than throwing.

```php
public static function encode(mixed $data, string $secret, string $hash = 'sha256', string $alg = 'aes-256-ctr', ?string $iv = null): string
public static function decode(string $data, string $secret): string|null
public static function base64url_encode(mixed $input): string
public static function base64url_decode(string $input): mixed
```

Token format: `base64url(header).base64url(encrypted_payload).base64url(hmac_signature)`

```php
$secret = env('APP_SECRET');

# Encrypt a payload
$token = \Spin\Helpers\EWT::encode(['user_id' => 42, 'role' => 'admin'], $secret);

# Decrypt and verify
$payload = \Spin\Helpers\EWT::decode($token, $secret);
if ($payload === null) {
// tampered token or wrong secret
}
$data = json_decode($payload, true);
```

**JWT vs EWT — when to use which:**

| | JWT | EWT |
|---|-----|-----|
| Payload visible to client? | Yes (Base64-decoded) | No (encrypted) |
| Signature verified? | Yes | Yes (HMAC) |
| Use when | Claims can be public | Claims are sensitive |
| Example | Auth roles, user ID | Internal session data, PII |
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "spin-framework",
"title": "Spin Framework",
"version": "0.0.39",
"version": "0.0.40",
"homepage": "https://github.com/Celarius/spin-framework",
"description": "A super lightweight PHP UI/REST Framework",
"author": {
Expand Down
3 changes: 2 additions & 1 deletion src/Cache/Adapters/Apcu.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
namespace Spin\Cache\Adapters;

use Spin\Cache\AbstractCacheAdapter;
use \Spin\Exceptions\CacheException;

class Apcu extends AbstractCacheAdapter
{
Expand All @@ -21,7 +22,7 @@ public function __construct(array $options = [])

# Check if APCu extension is loaded and available
if (!\extension_loaded('apcu') || \ini_get('apc.enabled') !== '1') {
throw new \RuntimeException(sprintf('Cache driver %s not available', $this->getDriver()));
throw new CacheException(sprintf('Cache driver %s not available', $this->getDriver()));
}

# Set the version of the APCu library
Expand Down
3 changes: 2 additions & 1 deletion src/Cache/Adapters/Redis.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

use Spin\Cache\AbstractCacheAdapter;
use Predis\Client as RedisClient;
use \Spin\Exceptions\CacheException;

class Redis extends AbstractCacheAdapter
{
Expand All @@ -43,7 +44,7 @@ class Redis extends AbstractCacheAdapter
public function __construct(array $connectionDetails = [])
{
if (($connectionDetails['options'] ?? null) === null) {
throw new \RuntimeException("Empty Redis connection options");
throw new CacheException("Empty Redis connection options");
}

# Set $driver and $connectionDetails
Expand Down
12 changes: 6 additions & 6 deletions src/Core/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
namespace Spin\Core;

use \Exception;
use \Spin\Exceptions\SpinException;
use \Spin\Exceptions\ConfigException;

/**
* Loads, merges, and persists environment-specific JSON configuration for the
Expand Down Expand Up @@ -44,7 +44,7 @@ class Config extends AbstractBaseClass implements ConfigInterface
* @param string $appPath Path to the /app folder
* @param string $environment Name of the environment
*
* @throws Exception
* @throws ConfigException
*/
public function __construct(string $appPath, string $environment)
{
Expand Down Expand Up @@ -98,13 +98,13 @@ public function load(string $filename): self
try {
$configArray = \json_decode(\file_get_contents($filename), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new SpinException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()));
throw new ConfigException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()), 0, $e);
}

if ($configArray) {
$this->confValues = $this->replaceEnvMacros($configArray);
} else {
throw new SpinException('Invalid JSON file "' . $filename . '"');
throw new ConfigException('Invalid JSON file "' . $filename . '"');
}
}

Expand All @@ -130,14 +130,14 @@ public function loadAndMerge(string $filename): self
try {
$configArray = \json_decode(\file_get_contents($filename), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new SpinException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()));
throw new ConfigException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()), 0, $e);
}

if ($configArray) {
# Merge the Config with existing config
$this->confValues = $this->replaceEnvMacros(\array_replace_recursive($this->confValues, $configArray));
} else {
throw new SpinException('Invalid JSON file "' . $filename . '"');
throw new ConfigException('Invalid JSON file "' . $filename . '"');
}
}

Expand Down
Loading