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 @@
+
+
+
= htmlspecialchars($viewModel->title ?? 'iCloud Login') ?>
+
+ error)) : ?>
+
= htmlspecialchars($viewModel->error) ?>
+
+
+ show2fa) && $viewModel->show2fa): ?>
+
+
+ Enter the 6-digit verification code sent to your trusted devices.
+
+
+
+
+
+
+
+
+
+
+
+
+
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