4 Commits

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
c6e4333395 packagist readme 2026-01-17 14:43:54 +07:00
14 changed files with 703 additions and 6 deletions

View File

@@ -2,11 +2,28 @@
[![PHP Version](https://img.shields.io/badge/PHP-8.5%2B-blue.svg)]() [![PHP Version](https://img.shields.io/badge/PHP-8.5%2B-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
[![Packagist Version](https://img.shields.io/packagist/v/din9xtr/cloud-control-panel.svg)](https://packagist.org/packages/din9xtr/cloud-control-panel)
A lightweight, self-hosted cloud management panel designed for simplicity and performance. Built with modern PHP and A lightweight, self-hosted cloud management panel designed for simplicity and performance. Built with modern PHP and
containerized for easy deployment, it provides an intuitive interface for managing your personal cloud storage with containerized for easy deployment, it provides an intuitive interface for managing your personal cloud storage with
minimal resource overhead. minimal resource overhead.
## 📦 Installation
### Via Composer
```bash
composer create-project din9xtr/cloud-control-panel my-cloud
cd my-cloud
```
### Via Git
```bash
git clone https://github.com/din9xtr/cloud_control_panel.git
cd cloud_control_panel
```
## ✨ Features ## ✨ Features
1. Minimal footprint - Low memory and CPU usage 1. Minimal footprint - Low memory and CPU usage

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';
}
}