3 Commits
main ... icloud

Author SHA1 Message Date
6c16245040 icloud auth done 2026-01-19 00:54:59 +07:00
c6df7f956d fix 2fa 2026-01-18 22:27:27 +07:00
a43d38d87f feat: add iCloud integration 2026-01-18 22:17:13 +07:00
13 changed files with 686 additions and 6 deletions

View File

@@ -33,7 +33,9 @@
"relay/relay": "^3.0", "relay/relay": "^3.0",
"ext-pdo": "*", "ext-pdo": "*",
"ext-fileinfo": "*", "ext-fileinfo": "*",
"ext-zip": "*" "ext-zip": "*",
"php-http/curl-client": "^2.4",
"ext-sodium": "*"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
@@ -59,7 +61,8 @@
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
"phpstan/extension-installer": true "phpstan/extension-installer": true,
"php-http/discovery": true
} }
} }
} }

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
return function (PDO $db): void {
$db->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,
password TEXT NOT NULL,
trust_token TEXT NOT NULL,
cookies TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'connected',
connected_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
");
};

View File

@@ -12,16 +12,40 @@ services:
- ./db:/var/db - ./db:/var/db
- ./php/php.ini:/usr/local/etc/php/conf.d/uploads.ini - ./php/php.ini:/usr/local/etc/php/conf.d/uploads.ini
- ./Caddyfile:/etc/frankenphp/Caddyfile - ./Caddyfile:/etc/frankenphp/Caddyfile
# - caddy_data:/data # - caddy_data:/data
# - caddy_config:/config # - caddy_config:/config
env_file: env_file:
- .env - .env
tty: false tty: false
networks:
- internal
healthcheck: healthcheck:
test: [ "CMD", "curl", "-kf", "http://localhost/" ] test: [ "CMD", "curl", "-kf", "http://localhost/" ]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 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: #volumes:
# caddy_data: # caddy_data:
# caddy_config: # caddy_config:

View File

@@ -48,7 +48,7 @@ opcache.enable_cli = 1
opcache.memory_consumption = 256 opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000 opcache.max_accelerated_files = 20000
opcache.interned_strings_buffer = 16 opcache.interned_strings_buffer = 16
opcache.validate_timestamps = 0 opcache.validate_timestamps = 1
opcache.revalidate_freq = 2 opcache.revalidate_freq = 2
opcache.max_wasted_percentage = 50 opcache.max_wasted_percentage = 50
; JIT ; JIT

View File

@@ -2,6 +2,9 @@
require dirname(__DIR__) . '/vendor/autoload.php'; require dirname(__DIR__) . '/vendor/autoload.php';
use Din9xtrCloud\Controllers\ICloudAuthController;
use Din9xtrCloud\Rclone\RcloneClient;
use Http\Client\Curl\Client;
use Din9xtrCloud\App; use Din9xtrCloud\App;
use Din9xtrCloud\Container\Container; use Din9xtrCloud\Container\Container;
use Din9xtrCloud\Controllers\AuthController; use Din9xtrCloud\Controllers\AuthController;
@@ -23,7 +26,11 @@ use Monolog\Processor\PsrLogMessageProcessor;
use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Factory\Psr17Factory;
use Monolog\Level; use Monolog\Level;
use Nyholm\Psr7Server\ServerRequestCreator; 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\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -77,6 +84,30 @@ $container->request(
)->fromGlobals() )->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 // Routes
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -108,6 +139,9 @@ $routes = static function (RouteCollector $r): void {
$r->get('/storage/files/download/multiple', [StorageController::class, 'downloadMultiple']); $r->get('/storage/files/download/multiple', [StorageController::class, 'downloadMultiple']);
$r->post('/storage/files/delete/multiple', [StorageController::class, 'deleteMultiple']); $r->post('/storage/files/delete/multiple', [StorageController::class, 'deleteMultiple']);
$r->get('/icloud/connect', [ICloudAuthController::class, 'connectForm']);
$r->post('/icloud/connect', [ICloudAuthController::class, 'submit']);
}; };

View File

@@ -0,0 +1,62 @@
<div class="login-card">
<h1>Connect iCloud</h1>
<?php if (!empty($viewModel->error)) : ?>
<p class="error"><?= htmlspecialchars($viewModel->error) ?></p>
<?php endif; ?>
<div class="info-message">
<ol>
<li>Open <b>icloud.com</b> and log in</li>
<li>Complete 2FA</li>
<li>Open DevTools → Network</li>
<li>Copy <b>Request Header → Cookie</b></li>
<li>Copy <b>X-APPLE-WEBAUTH-HSA-TRUST</b> value</li>
</ol>
</div>
<form method="POST" action="/icloud/connect" class="login-form">
<label for="apple_id">Apple ID</label>
<input
type="email"
id="apple_id"
name="apple_id"
required
placeholder="yourname@icloud.com"
>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
placeholder="iCloud password"
>
<label for="cookies">Cookies (full header)</label>
<textarea
id="cookies"
name="cookies"
rows="6"
required
placeholder="X-APPLE-WEBAUTH-HSA-TRUST=...; X-APPLE-WEBAUTH-USER=...;"
></textarea>
<label for="trust_token">Trust token</label>
<input
type="text"
id="trust_token"
name="trust_token"
required
placeholder="X-APPLE-WEBAUTH-HSA-TRUST value"
>
<button type="submit">
Connect iCloud
</button>
<input type="hidden" name="_csrf"
value="<?= htmlspecialchars($viewModel->csrf) ?>">
</form>
</div>

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\Middlewares\CsrfMiddleware;
use Din9xtrCloud\Rclone\RcloneClient;
use Din9xtrCloud\Repositories\IcloudAccountRepository;
use Din9xtrCloud\View;
use Din9xtrCloud\ViewModels\Icloud\ICloudLoginViewModel;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Throwable;
final readonly class ICloudAuthController
{
public function __construct(
private ResponseFactoryInterface $responseFactory,
private LoggerInterface $logger,
private IcloudAccountRepository $repository,
private RcloneClient $rclone,
)
{
}
public function connectForm(ServerRequestInterface $request): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$error = $_SESSION['icloud_error'] ?? '';
unset($_SESSION['icloud_error']);
return View::display(
new ICloudLoginViewModel(
title: 'Connect iCloud',
csrf: CsrfMiddleware::generateToken($request),
error: $error
)
);
}
public function submit(ServerRequestInterface $request): ResponseInterface
{
$data = (array)$request->getParsedBody();
$user = $request->getAttribute('user');
$appleId = trim((string)($data['apple_id'] ?? ''));
$password = trim((string)($data['password'] ?? ''));
$cookies = trim((string)($data['cookies'] ?? ''));
$trustToken = trim((string)($data['trust_token'] ?? ''));
if ($appleId === '' || $password === '' || $cookies === '' || $trustToken === '') {
return $this->fail('All fields are required');
}
$remote = 'icloud_' . $user->id;
try {
$encryptedAppleId = encryptString($appleId);
$encryptedPassword = encryptString($password);
try {
$this->rclone->call('config/create', [
'name' => $remote,
'type' => 'iclouddrive',
'parameters' => [
'apple_id' => $appleId,
'password' => $password,
'cookies' => $cookies,
'trust_token' => $trustToken,
],
'nonInteractive' => true,
]);
} catch (Throwable $e) {
$this->logger->warning('iCloud remote exists, updating', [
'remote' => $remote,
'exception' => $e,
]);
$this->rclone->call('config/update', [
'name' => $remote,
'parameters' => [
'apple_id' => $appleId,
'password' => $password,
'cookies' => $cookies,
'trust_token' => $trustToken,
],
]);
}
// health-check
$list = $this->rclone->call('operations/list', [
'fs' => $remote . ':',
'remote' => '',
'maxDepth' => 1,
]);
$this->logger->info('icloud list', $list);
$this->repository->createOrUpdate(
userId: $user->id,
remoteName: $remote,
appleId: $encryptedAppleId,
password: $encryptedPassword,
trustToken: $trustToken,
cookies: $cookies,
);
return $this->responseFactory
->createResponse(302)
->withHeader('Location', '/storage');
} catch (Throwable $e) {
$this->logger->error('iCloud connect failed', [
'remote' => $remote,
'exception' => $e,
]);
return $this->fail($e->getMessage());
}
}
private function fail(string $message): ResponseInterface
{
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['icloud_error'] = $message;
return $this->responseFactory
->createResponse(302)
->withHeader('Location', '/icloud/connect');
}
}

View File

@@ -1,5 +1,7 @@
<?php <?php
use Random\RandomException;
if (!function_exists('formatBytes')) { if (!function_exists('formatBytes')) {
function formatBytes(int $bytes): string function formatBytes(int $bytes): string
@@ -94,3 +96,48 @@ if (!function_exists('validateIp')) {
: null; : null;
} }
} }
if (!function_exists('encryptString')) {
/**
* XSalsa20 + Poly1305
* @throws Throwable
*/
function encryptString(string $plaintext): string
{
$key = hex2bin($_ENV['APP_KEY']); // 32 байта
if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new RuntimeException('APP_KEY must be 32 bytes');
}
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
return base64_encode($nonce . $ciphertext);
}
}
if (!function_exists('decryptString')) {
/**
* @throws Throwable
*/
function decryptString(string $encrypted): string
{
$key = hex2bin($_ENV['APP_KEY']);
if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new RuntimeException('APP_KEY must be 32 bytes');
}
$data = base64_decode($encrypted, true);
$nonce = substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
if ($plaintext === false) {
throw new RuntimeException('Failed to decrypt data');
}
return $plaintext;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Models;
final readonly class IcloudAccount
{
public function __construct(
public int $id,
public int $userId,
public string $remoteName,
public string $appleId,
public string $password,
public string $trustToken,
public string $cookies,
public string $status,
public int $connectedAt,
public int $createdAt,
)
{
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Rclone;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use RuntimeException;
final readonly class RcloneClient
{
public function __construct(
private ClientInterface $http,
private RequestFactoryInterface $requests,
private StreamFactoryInterface $streams,
private string $baseUrl,
private string $user,
private string $pass,
)
{
}
/**
* @param string $method rc endpoint without
* @param array $params JSON body
* @return array
* @throws JsonException
* @throws ClientExceptionInterface
*/
public function call(string $method, array $params = []): array
{
$url = rtrim($this->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);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Repositories;
use Din9xtrCloud\Models\IcloudAccount;
use PDO;
final readonly class IcloudAccountRepository
{
public function __construct(private PDO $db)
{
}
public function createOrUpdate(
int $userId,
string $remoteName,
string $appleId,
string $password,
string $trustToken,
string $cookies
): IcloudAccount
{
$existing = $this->findByUserId($userId);
$now = time();
if ($existing) {
$stmt = $this->db->prepare("
UPDATE icloud_accounts
SET apple_id = :apple_id,
password = :password,
trust_token = :trust_token,
cookies = :cookies,
status = 'connected',
connected_at = :connected_at
WHERE id = :id
");
$stmt->execute([
':apple_id' => $appleId,
':password' => $password,
':trust_token' => $trustToken,
':cookies' => $cookies,
':connected_at' => $now,
':id' => $existing->id,
]);
return new IcloudAccount(
id: $existing->id,
userId: $userId,
remoteName: $remoteName,
appleId: $appleId,
password: $password,
trustToken: $trustToken,
cookies: $cookies,
status: 'connected',
connectedAt: $now,
createdAt: $existing->createdAt,
);
}
$stmt = $this->db->prepare("
INSERT INTO icloud_accounts
(user_id, remote_name, apple_id, password, trust_token, cookies, status, connected_at, created_at)
VALUES
(:user_id, :remote_name, :apple_id, :password, :trust_token, :cookies, 'connected', :connected_at, :created_at)
");
$stmt->execute([
':user_id' => $userId,
':remote_name' => $remoteName,
':apple_id' => $appleId,
':password' => $password,
':trust_token' => $trustToken,
':cookies' => $cookies,
':connected_at' => $now,
':created_at' => $now,
]);
return new IcloudAccount(
id: (int)$this->db->lastInsertId(),
userId: $userId,
remoteName: $remoteName,
appleId: $appleId,
password: $password,
trustToken: $trustToken,
cookies: $cookies,
status: 'connected',
connectedAt: $now,
createdAt: $now,
);
}
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'],
password: $row['password'],
trustToken: $row['trust_token'],
cookies: $row['cookies'],
status: $row['status'],
connectedAt: (int)$row['connected_at'],
createdAt: (int)$row['created_at'],
) : null;
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage\Drivers;
use Din9xtrCloud\Rclone\RcloneClient;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
final readonly class ICloudStorageDriver implements StorageDriverInterface
{
public function __construct(
private RcloneClient $rclone,
private string $remoteName,
)
{
}
/**
* @throws ClientExceptionInterface
* @throws JsonException
*/
public function getUsedBytes(int|string $userId): int
{
$stats = $this->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.
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels\Icloud;
use Din9xtrCloud\ViewModels\BaseViewModel;
use Din9xtrCloud\ViewModels\LayoutConfig;
final readonly class ICloudLoginViewModel extends BaseViewModel
{
public function __construct(
string $title,
public ?string $csrf = null,
public bool $show2fa = false,
public string $error = '',
public string $appleId = ''
)
{
$layoutConfig = new LayoutConfig(
header: null,
showFooter: true,
);
parent::__construct($layoutConfig, $title);
}
public function title(): string
{
return $this->title;
}
public function template(): string
{
return 'login_icloud';
}
}