diff --git a/src/Application/ImageUploadService.php b/src/Application/ImageUploadService.php new file mode 100644 index 0000000..c375eee --- /dev/null +++ b/src/Application/ImageUploadService.php @@ -0,0 +1,38 @@ +uploadsRoot . '/' . $this->userToken; + if (!is_dir($namespace)) { + mkdir($namespace, 0700, true); + } + + $hash = bin2hex(random_bytes(16)); + $filename = $hash . '.' . $validated->extension; + $destination = $namespace . '/' . $filename; + + if (!rename($tempPath, $destination)) { + throw new InvalidImageException("Could not move upload to {$destination}."); + } + + // Lock the file down: the namespace is already 0700; tighten the + // file itself to 0600 in case the server's umask is permissive. + chmod($destination, 0600); + + return '/assets/' . $this->userToken . '/' . $filename; + } +} diff --git a/tests/Unit/Application/ImageUploadServiceTest.php b/tests/Unit/Application/ImageUploadServiceTest.php new file mode 100644 index 0000000..a80cfdd --- /dev/null +++ b/tests/Unit/Application/ImageUploadServiceTest.php @@ -0,0 +1,94 @@ +tmpDir = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4)); + mkdir($this->tmpDir, 0700, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*/*') as $file) { + unlink($file); + } + foreach (glob($this->tmpDir . '/*') as $dir) { + rmdir($dir); + } + rmdir($this->tmpDir); + } + } + + public function testItStoresAValidImageAndReturnsAStableUrl(): void + { + $service = new ImageUploadService('user-token-1', $this->tmpDir); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, self::validPng(8, 8)); + + $url = $service->store($tmp, 'image/png'); + + self::assertStringStartsWith('/assets/user-token-1/', $url); + self::assertStringEndsWith('.png', $url); + + $stored = $this->tmpDir . '/user-token-1/' . basename($url); + self::assertFileExists($stored); + } + + public function testItCreatesTheUserNamespaceDirectory(): void + { + $service = new ImageUploadService('fresh-user', $this->tmpDir); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, self::validPng(8, 8)); + + $service->store($tmp, 'image/png'); + + self::assertDirectoryExists($this->tmpDir . '/fresh-user'); + } + + public function testItProducesUniqueFilenamesForRepeatedUploads(): void + { + $service = new ImageUploadService('user-token-2', $this->tmpDir); + $tmp1 = tempnam(sys_get_temp_dir(), 'bf-up'); + $tmp2 = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp1, self::validPng(8, 8)); + file_put_contents($tmp2, self::validPng(8, 8)); + + $url1 = $service->store($tmp1, 'image/png'); + $url2 = $service->store($tmp2, 'image/png'); + + self::assertNotSame($url1, $url2); + } + + public function testItRejectsAnInvalidImage(): void + { + $service = new ImageUploadService('user-token-3', $this->tmpDir); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, 'not an image'); + + $this->expectException(\BattleForge\Application\InvalidImageException::class); + $service->store($tmp, 'image/png'); + } + + private static function validPng(int $width, int $height): string + { + if ($width === 8 && $height === 8) { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' . + 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC', + true, + ); + } + throw new \InvalidArgumentException('Only 8x8 base PNG supported in tests.'); + } +}