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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ desktop.ini
/storage/*.sqlite3
/storage/*.db
/storage/attachments/
/.phpsockets/
/examples/**/storage/*.sqlite
/examples/**/storage/*.sqlite3
/examples/**/storage/*.db
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ PHPSOCKETS_HOST=127.0.0.1
PHPSOCKETS_PORT=8080
PHPSOCKETS_MAX_PAYLOAD_BYTES=4194304
PHPSOCKETS_MAX_ATTACHMENT_BYTES=2097152
PHPSOCKETS_ATTACHMENT_DIR=.phpsockets/attachments
PHPSOCKETS_STORAGE=memory
PHPSOCKETS_DEBUG=true
```
Expand Down Expand Up @@ -562,6 +563,22 @@ php artisan phpsockets:migrate --driver=sqlite --database=database/phpsockets.sq

The examples support small file messages.

### Attachment runtime directory

By default, PHPSockets stores temporary example attachments in a project-local directory:

```txt
.phpsockets/attachments
```

You can override this location with:

```env
PHPSOCKETS_ATTACHMENT_DIR=/absolute/path/to/attachments
```

This is especially useful for production deployments, Laravel apps, containers and Windows environments.

Supported MIME types:

```txt
Expand Down
3 changes: 2 additions & 1 deletion examples/easy-chat/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@

$host = getenv('PHPSOCKETS_HOST') ?: '127.0.0.1';
$port = (int) (getenv('PHPSOCKETS_PORT') ?: 8080);
$publicPath = str_replace('\\', '/', __DIR__ . '/public');

echo "PHPSockets EasyChat server running on ws://{$host}:{$port}\n";
echo "Open the browser UI with: php -S 127.0.0.1:8000 -t examples/easy-chat/public\n";
echo "Open the browser UI with: php -S 127.0.0.1:8000 -t {$publicPath}\n";
echo "Press Ctrl+C to stop the WebSocket server.\n\n";

$server = ChatServer::create(
Expand Down
3 changes: 2 additions & 1 deletion examples/medium-chat/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@

$host = getenv('PHPSOCKETS_HOST') ?: '127.0.0.1';
$port = (int) (getenv('PHPSOCKETS_PORT') ?: 8080);
$publicPath = str_replace('\\', '/', __DIR__ . '/public');

echo "PHPSockets MediumChat server running on ws://{$host}:{$port}\n";
echo "Open the browser UI with: php -S 127.0.0.1:8001 -t examples/medium-chat/public\n";
echo "Open the browser UI with: php -S 127.0.0.1:8001 -t {$publicPath}\n";
echo "Press Ctrl+C to stop the WebSocket server.\n\n";

$server = ChatServer::create(
Expand Down
3 changes: 2 additions & 1 deletion examples/private-chat/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@

$host = getenv('PHPSOCKETS_HOST') ?: '127.0.0.1';
$port = (int) (getenv('PHPSOCKETS_PORT') ?: 8080);
$publicPath = str_replace('\\', '/', __DIR__ . '/public');

echo "PHPSockets PrivateChat server running on ws://{$host}:{$port}\n";
echo "Open the browser UI with: php -S 127.0.0.1:8002 -t examples/private-chat/public\n";
echo "Open the browser UI with: php -S 127.0.0.1:8002 -t {$publicPath}\n";
echo "Press Ctrl+C to stop the WebSocket server.\n\n";

$server = ChatServer::create(
Expand Down
3 changes: 2 additions & 1 deletion src/Chat/ChatKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Micilini\PhpSockets\Storage\InMemory\InMemoryMessageStore;
use Micilini\PhpSockets\Storage\InMemory\InMemoryRoomStore;
use Micilini\PhpSockets\Storage\InMemory\InMemorySessionStore;
use Micilini\PhpSockets\Support\RuntimePath;
use Throwable;

final class ChatKernel
Expand Down Expand Up @@ -57,7 +58,7 @@ public function __construct(
$this->rooms = $roomStore ?? new InMemoryRoomStore();
$this->validator = new PayloadValidator();
$this->attachmentValidator = new AttachmentValidator($this->config);
$this->attachments = $attachmentStore ?? new FileAttachmentStore(sys_get_temp_dir() . '/phpsockets-attachments');
$this->attachments = $attachmentStore ?? new FileAttachmentStore(RuntimePath::attachmentsDirectory());
$this->bots = $botManager ?? new BotManager();
$this->presence = new PresenceManager(
new UsernameNormalizer($this->config->maxDisplayNameLength),
Expand Down
14 changes: 13 additions & 1 deletion src/Storage/File/FileAttachmentStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,23 @@ private function metadataPath(string $attachmentId): string
private function ensureDirectory(string $path): void
{
if (is_dir($path)) {
if (!is_writable($path)) {
throw new StorageException("Attachment directory is not writable: {$path}");
}

return;
}

if (!mkdir($path, 0775, true) && !is_dir($path)) {
if (file_exists($path)) {
throw new StorageException("Attachment path exists but is not a directory: {$path}");
}

if (!@mkdir($path, 0775, true) && !is_dir($path)) {
throw new StorageException("Failed to create attachment directory: {$path}");
}

if (!is_writable($path)) {
throw new StorageException("Attachment directory is not writable: {$path}");
}
}
}
52 changes: 52 additions & 0 deletions src/Support/RuntimePath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Support;

final class RuntimePath
{
public static function attachmentsDirectory(): string
{
$configuredPath = getenv('PHPSOCKETS_ATTACHMENT_DIR');

if (is_string($configuredPath) && trim($configuredPath) !== '') {
return self::normalize($configuredPath);
}

$workingDirectory = getcwd();

if (is_string($workingDirectory) && $workingDirectory !== '') {

Check failure on line 19 in src/Support/RuntimePath.php

View workflow job for this annotation

GitHub Actions / PHP 8.2

Strict comparison using !== between non-empty-string and '' will always evaluate to true.

Check failure on line 19 in src/Support/RuntimePath.php

View workflow job for this annotation

GitHub Actions / PHP 8.4

Strict comparison using !== between non-empty-string and '' will always evaluate to true.

Check failure on line 19 in src/Support/RuntimePath.php

View workflow job for this annotation

GitHub Actions / PHP 8.3

Strict comparison using !== between non-empty-string and '' will always evaluate to true.
return self::join($workingDirectory, '.phpsockets', 'attachments');
}

return self::join(
sys_get_temp_dir(),
'phpsockets-' . substr(hash('sha256', __DIR__), 0, 12),
'attachments',
);
}

private static function join(string $basePath, string ...$segments): string
{
$path = rtrim($basePath, '/\\');

if ($path === '') {
$path = DIRECTORY_SEPARATOR;
}

foreach ($segments as $segment) {
$path .= DIRECTORY_SEPARATOR . trim($segment, '/\\');
}

return $path;
}

private static function normalize(string $path): string
{
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, trim($path));
$path = rtrim($path, DIRECTORY_SEPARATOR);

return $path !== '' ? $path : DIRECTORY_SEPARATOR;
}
}
25 changes: 25 additions & 0 deletions tests/Unit/Examples/ExampleServerAutoloadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,29 @@ public function testSharedBootstrapSupportsRepositoryAndComposerInstallPaths():
self::assertStringContainsString("__DIR__ . '/../../../autoload.php'", $bootstrap);
self::assertStringContainsString("getcwd() . '/vendor/autoload.php'", $bootstrap);
}

public function testExampleServersPrintDynamicPublicPaths(): void
{
$serverFiles = [
__DIR__ . '/../../../examples/easy-chat/server.php',
__DIR__ . '/../../../examples/medium-chat/server.php',
__DIR__ . '/../../../examples/private-chat/server.php',
];

foreach ($serverFiles as $serverFile) {
$contents = (string) file_get_contents($serverFile);

self::assertStringContainsString(
'$publicPath = str_replace',
$contents,
"{$serverFile} should compute its public path dynamically.",
);

self::assertStringContainsString(
'{$publicPath}',
$contents,
"{$serverFile} should print the dynamic public path.",
);
}
}
}
19 changes: 19 additions & 0 deletions tests/Unit/Storage/FileAttachmentStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Micilini\PhpSockets\Tests\Unit\Storage;

use Micilini\PhpSockets\Chat\Attachment;
use Micilini\PhpSockets\Exceptions\StorageException;
use Micilini\PhpSockets\Storage\File\FileAttachmentStore;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -51,4 +52,22 @@ public function testSavesContentMetadataAndFindsAttachment(): void
self::assertFileExists($loaded->path);
self::assertFileExists($this->directory . DIRECTORY_SEPARATOR . $attachment->id . '.json');
}

public function testConstructorFailsWhenAttachmentPathExistsAsFile(): void
{
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpsockets-attachment-file-conflict-' . uniqid('', true);

file_put_contents($path, 'not a directory');

try {
$this->expectException(StorageException::class);
$this->expectExceptionMessage('Attachment path exists but is not a directory');

new FileAttachmentStore($path);
} finally {
if (is_file($path)) {
unlink($path);
}
}
}
}
38 changes: 38 additions & 0 deletions tests/Unit/Support/RuntimePathTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Tests\Unit\Support;

use Micilini\PhpSockets\Support\RuntimePath;
use PHPUnit\Framework\TestCase;

final class RuntimePathTest extends TestCase
{
protected function tearDown(): void
{
putenv('PHPSOCKETS_ATTACHMENT_DIR');

parent::tearDown();
}

public function testAttachmentsDirectoryUsesEnvironmentOverride(): void
{
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'custom-phpsockets-attachments';

putenv('PHPSOCKETS_ATTACHMENT_DIR=' . $path);

self::assertSame($path, RuntimePath::attachmentsDirectory());
}

public function testAttachmentsDirectoryDefaultsToProjectLocalDirectory(): void
{
putenv('PHPSOCKETS_ATTACHMENT_DIR');

$path = RuntimePath::attachmentsDirectory();

self::assertStringContainsString('.phpsockets', $path);
self::assertStringContainsString('attachments', $path);
self::assertStringStartsWith((string) getcwd(), $path);
}
}
Loading