diff --git a/composer.json b/composer.json index 37a739e..6b1032f 100755 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "relay/relay": "^3.0", "ext-pdo": "*", "ext-fileinfo": "*", - "ext-zip": "*" + "ext-zip": "*", + "php-http/curl-client": "^2.4" }, "require-dev": { "phpstan/phpstan": "^2.1", @@ -59,7 +60,8 @@ }, "config": { "allow-plugins": { - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "php-http/discovery": true } } } diff --git a/db/migration/005_create_icloud_accounts.php b/db/migration/005_create_icloud_accounts.php new file mode 100644 index 0000000..9097cd7 --- /dev/null +++ b/db/migration/005_create_icloud_accounts.php @@ -0,0 +1,21 @@ +exec("PRAGMA foreign_keys = ON;"); + + $db->exec(" + CREATE TABLE IF NOT EXISTS icloud_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + remote_name TEXT NOT NULL UNIQUE, + apple_id TEXT NOT NULL, + trust_token TEXT, + cookies TEXT, + status TEXT NOT NULL DEFAULT 'pending', + connected_at INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + "); +}; diff --git a/docker-compose.yml b/docker-compose.yml index a78cc3f..25df0c9 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,16 +12,40 @@ services: - ./db:/var/db - ./php/php.ini:/usr/local/etc/php/conf.d/uploads.ini - ./Caddyfile:/etc/frankenphp/Caddyfile -# - caddy_data:/data -# - caddy_config:/config + # - caddy_data:/data + # - caddy_config:/config env_file: - .env tty: false + networks: + - internal healthcheck: test: [ "CMD", "curl", "-kf", "http://localhost/" ] interval: 30s timeout: 10s retries: 3 + rclone: + image: rclone/rclone:latest + container_name: rclone + restart: unless-stopped + command: + - rcd + - --rc-addr=:5572 + - --rc-user=${RCLONE_USER} + - --rc-pass=${RCLONE_PASS} + - --config=/config/rclone.conf + - --cache-dir=/cache + volumes: + - rclone_config:/config + - rclone_cache:/cache + networks: + - internal +networks: + internal: + +volumes: + rclone_config: + rclone_cache: #volumes: # caddy_data: # caddy_config: diff --git a/php/php.ini b/php/php.ini index df279d9..0d08ca9 100644 --- a/php/php.ini +++ b/php/php.ini @@ -48,7 +48,7 @@ opcache.enable_cli = 1 opcache.memory_consumption = 256 opcache.max_accelerated_files = 20000 opcache.interned_strings_buffer = 16 -opcache.validate_timestamps = 0 +opcache.validate_timestamps = 1 opcache.revalidate_freq = 2 opcache.max_wasted_percentage = 50 ; JIT diff --git a/public/index.php b/public/index.php index cb4bc29..12fc346 100755 --- a/public/index.php +++ b/public/index.php @@ -2,6 +2,9 @@ require dirname(__DIR__) . '/vendor/autoload.php'; +use Din9xtrCloud\Controllers\ICloudAuthController; +use Din9xtrCloud\Rclone\RcloneClient; +use Http\Client\Curl\Client; use Din9xtrCloud\App; use Din9xtrCloud\Container\Container; use Din9xtrCloud\Controllers\AuthController; @@ -23,7 +26,11 @@ use Monolog\Processor\PsrLogMessageProcessor; use Nyholm\Psr7\Factory\Psr17Factory; use Monolog\Level; use Nyholm\Psr7Server\ServerRequestCreator; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; // --------------------------------------------------------------------- @@ -77,6 +84,30 @@ $container->request( )->fromGlobals() ); +$container->singleton(ClientInterface::class, fn() => new Client()); + +$container->singleton( + ResponseFactoryInterface::class, + fn(Container $c) => $c->get(Psr17Factory::class) +); +$container->singleton(RequestFactoryInterface::class, + fn(Container $c) => $c->get(Psr17Factory::class) +); + +$container->singleton(StreamFactoryInterface::class, + fn(Container $c) => $c->get(Psr17Factory::class) +); +//$container->factory(ResponseInterface); +$container->singleton(RcloneClient::class, function (Container $c) { + return new RcloneClient( + http: $c->get(ClientInterface::class), + requests: $c->get(RequestFactoryInterface::class), + streams: $c->get(StreamFactoryInterface::class), + baseUrl: $_ENV['RCLONE_URL'] ?? 'http://rclone:5572', + user: $_ENV['RCLONE_USER'], + pass: $_ENV['RCLONE_PASS'], + ); +}); // --------------------------------------------------------------------- // Routes // --------------------------------------------------------------------- @@ -108,6 +139,10 @@ $routes = static function (RouteCollector $r): void { $r->get('/storage/files/download/multiple', [StorageController::class, 'downloadMultiple']); $r->post('/storage/files/delete/multiple', [StorageController::class, 'deleteMultiple']); + + $r->get('/icloud/connect', [ICloudAuthController::class, 'connectForm']); + $r->post('/icloud/connect', [ICloudAuthController::class, 'submitCredentials']); + $r->post('/icloud/2fa', [ICloudAuthController::class, 'submit2fa']); }; diff --git a/resources/views/login_icloud.php b/resources/views/login_icloud.php new file mode 100644 index 0000000..a987a73 --- /dev/null +++ b/resources/views/login_icloud.php @@ -0,0 +1,114 @@ + +
+

title ?? 'iCloud Login') ?>

+ + error)) : ?> +

error) ?>

+ + + show2fa) && $viewModel->show2fa): ?> +
+

+ Enter the 6-digit verification code sent to your trusted devices. +

+
+ +
+ + + + + + + + +
+ + ← Back to login + +
+ + +
+ + +
+ + + + + + + + + +
+ +
+ + + + diff --git a/src/Controllers/ICloudAuthController.php b/src/Controllers/ICloudAuthController.php new file mode 100644 index 0000000..bf06a36 --- /dev/null +++ b/src/Controllers/ICloudAuthController.php @@ -0,0 +1,152 @@ +getQueryParams(); + $show2fa = (bool)($query['show2fa'] ?? false); + $appleId = (string)($query['apple_id'] ?? ''); + + $error = ''; + if (isset($_SESSION['icloud_error'])) { + $error = (string)$_SESSION['icloud_error']; + unset($_SESSION['icloud_error']); + } + + $title = $show2fa ? 'iCloud 2FA Verification' : 'iCloud Login'; + + return View::display( + new ICloudLoginViewModel( + title: $title, + csrf: CsrfMiddleware::generateToken($request), + show2fa: $show2fa, + error: $error, + appleId: $appleId + ) + ); + } + + public function submitCredentials(ServerRequestInterface $request): ResponseInterface + { + $data = (array)$request->getParsedBody(); + $user = $request->getAttribute('user'); + + $appleId = (string)($data['apple_id'] ?? ''); + $password = (string)($data['password'] ?? ''); + $remote = 'icloud_' . $user->id; + + try { + $this->configurator->createRemote($remote, $appleId); + $this->configurator->setPassword($remote, $password); + + $config = $this->configurator->getConfig($remote); + $trustToken = $config['trust_token'] ?? null; + $cookies = $config['cookies'] ?? null; + + $this->repository->create( + $user->id, + $remote, + $appleId, + $trustToken, + $cookies + ); + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', '/icloud/connect?show2fa=1&apple_id=' . urlencode($appleId)); + + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + + if (session_status() === PHP_SESSION_NONE) session_start(); + $_SESSION['icloud_error'] = $e->getMessage(); + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', '/icloud/connect'); + } + } + + + public function submit2fa(ServerRequestInterface $request): ResponseInterface + { + $data = (array)$request->getParsedBody(); + $user = $request->getAttribute('user'); + + $code = (string)($data['code'] ?? ''); + $appleId = (string)($data['apple_id'] ?? ''); + $remote = 'icloud_' . $user->id; + + try { + $this->configurator->submit2fa($remote, $code); + + $account = $this->repository->findByUserId($user->id); + if ($account) { + $this->repository->update($account, [ + 'status' => 'connected', + 'connected_at' => time() + ]); + } + + $contents = $this->rclone->call('operations/list', [ + 'fs' => $remote . ':', + 'remote' => '', + 'opt' => [], + 'recurse' => true, + 'dirsOnly' => false, + 'filesOnly' => false, + 'metadata' => true, + ]); + + $this->logger->info('iCloud contents', ['user' => $user->id, '$contents' => $contents]); + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', '/storage'); + + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + + if (session_status() === PHP_SESSION_NONE) session_start(); + $_SESSION['icloud_error'] = $e->getMessage(); + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', '/icloud/connect?' . http_build_query([ + 'show2fa' => 1, + 'apple_id' => $appleId + ])); + } + } + +} \ No newline at end of file diff --git a/src/Models/IcloudAccount.php b/src/Models/IcloudAccount.php new file mode 100644 index 0000000..797fec4 --- /dev/null +++ b/src/Models/IcloudAccount.php @@ -0,0 +1,21 @@ +baseUrl, '/') . '/' . ltrim($method, '/'); + + $body = $this->streams->createStream( + json_encode($params, JSON_THROW_ON_ERROR) + ); + + $request = $this->requests + ->createRequest('POST', $url) + ->withHeader( + 'Authorization', + 'Basic ' . base64_encode($this->user . ':' . $this->pass) + ) + ->withHeader('Content-Type', 'application/json') + ->withBody($body); + + $response = $this->http->sendRequest($request); + + $status = $response->getStatusCode(); + $content = (string)$response->getBody(); + + if ($status >= 400) { + throw new RuntimeException( + sprintf('Rclone API error %d: %s', $status, $content) + ); + } + + if ($content === '') { + return []; + } + + return json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Rclone/RcloneICloudConfigurator.php b/src/Rclone/RcloneICloudConfigurator.php new file mode 100644 index 0000000..11341b0 --- /dev/null +++ b/src/Rclone/RcloneICloudConfigurator.php @@ -0,0 +1,83 @@ +rclone->call('config/create', [ + 'name' => $remote, + 'type' => 'iclouddrive', + 'parameters' => [ + 'apple_id' => $appleId, + ], + 'opt' => [ + 'nonInteractive' => true, + ], + ]); + } + + /** + * @throws ClientExceptionInterface + * @throws JsonException + */ + public function setPassword( + string $remote, + string $password + ): void + { + $this->rclone->call('config/password', [ + 'name' => $remote, + 'parameters' => [ + 'password' => $password, + ], + ]); + } + + /** + * @throws ClientExceptionInterface + * @throws JsonException + */ + public function submit2fa( + string $remote, + string $code + ): void + { + $this->rclone->call('config/update', [ + 'name' => $remote, + 'parameters' => [ + 'config_2fa' => $code, + ], + ]); + } + + /** + * @throws ClientExceptionInterface + * @throws JsonException + */ + public function getConfig(string $remote): array + { + return $this->rclone->call('config/show', [ + 'name' => $remote + ]); + } + +} diff --git a/src/Repositories/IcloudAccountRepository.php b/src/Repositories/IcloudAccountRepository.php new file mode 100644 index 0000000..07ad72b --- /dev/null +++ b/src/Repositories/IcloudAccountRepository.php @@ -0,0 +1,83 @@ +db->prepare(" + INSERT INTO icloud_accounts (user_id, remote_name, apple_id, trust_token, cookies) + VALUES (:user_id, :remote_name, :apple_id, :trust_token, :cookies) + "); + $stmt->execute([ + ':user_id' => $userId, + ':remote_name' => $remoteName, + ':apple_id' => $appleId, + ':trust_token' => $trustToken, + ':cookies' => $cookies, + ]); + + $id = (int)$this->db->lastInsertId(); + + return new IcloudAccount( + id: $id, + userId: $userId, + remoteName: $remoteName, + appleId: $appleId, + trustToken: $trustToken, + cookies: $cookies, + status: 'pending', + connectedAt: null, + createdAt: time(), + ); + } + + public function update( + IcloudAccount $account, + array $fields + ): void + { + $set = []; + $params = [':id' => $account->id]; + foreach ($fields as $key => $value) { + $set[] = "$key = :$key"; + $params[":$key"] = $value; + } + $stmt = $this->db->prepare("UPDATE icloud_accounts SET " . implode(', ', $set) . " WHERE id = :id"); + $stmt->execute($params); + } + + public function findByUserId(int $userId): ?IcloudAccount + { + $stmt = $this->db->prepare("SELECT * FROM icloud_accounts WHERE user_id = :user_id"); + $stmt->execute([':user_id' => $userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? new IcloudAccount( + id: (int)$row['id'], + userId: (int)$row['user_id'], + remoteName: $row['remote_name'], + appleId: $row['apple_id'], + trustToken: $row['trust_token'], + cookies: $row['cookies'], + status: $row['status'], + connectedAt: $row['connected_at'] ? (int)$row['connected_at'] : null, + createdAt: (int)$row['created_at'], + ) : null; + } +} diff --git a/src/Storage/Drivers/ICloudStorageDriver.php b/src/Storage/Drivers/ICloudStorageDriver.php new file mode 100644 index 0000000..7f333f6 --- /dev/null +++ b/src/Storage/Drivers/ICloudStorageDriver.php @@ -0,0 +1,104 @@ +rclone->call('operations/about', [ + 'fs' => $this->remoteName . ':', + ]); + + return (int)($stats['used'] ?? 0); + } + + /** + * @throws ClientExceptionInterface + * @throws JsonException + */ + public function createFolder(int|string $userId, string $name): void + { + $this->rclone->call('operations/mkdir', [ + 'fs' => $this->remoteName . ':', + 'remote' => $name, + ]); + } + + public function getTotalBytes(int|string $userId): int + { + return 0; + } + + /** + * @inheritDoc + */ + public function getUsedBytesByCategory(int|string $userId): array + { + return []; + } + + public function storeUploadedFile(int|string $userId, string $folder, UploadedFileInterface $file, string $targetName): void + { + // TODO: Implement storeUploadedFile() method. + } + + public function getUserFolderPath(int|string $userId, string $folder): string + { + return ''; + } + + /** + * @inheritDoc + */ + public function tusInit(int|string $userId, string $uploadId, int $size, array $metadata): void + { + // TODO: Implement tusInit() method. + } + + public function tusWriteChunk(int|string $userId, string $uploadId, int $offset, StreamInterface $stream): int + { + return 0; + } + + /** + * @inheritDoc + */ + public function tusGetStatus(int|string $userId, string $uploadId): array + { + return []; + } + + public function getUserPath(int|string $userId): string + { + return ''; + } + + public function getFilePath(int|string $userId, string $folder, string $filename): string + { + return ''; + } + + public function deleteFile(int|string $userId, string $folder, string $filename): void + { + // TODO: Implement deleteFile() method. + } +} diff --git a/src/ViewModels/Icloud/ICloudLoginViewModel.php b/src/ViewModels/Icloud/ICloudLoginViewModel.php new file mode 100644 index 0000000..36e7b16 --- /dev/null +++ b/src/ViewModels/Icloud/ICloudLoginViewModel.php @@ -0,0 +1,35 @@ +title; + } + + public function template(): string + { + return 'login_icloud'; + } +} \ No newline at end of file