yii2-netdisk/controllers/VaultController.php
Chenx221 f83c7ed325
实现文件保险箱的端到端加密
*未完全测试
*不保证数据安全性
2024-03-11 20:24:51 +08:00

296 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
* 保险箱设计: 仅文件上传下载及删除,不支持文件夹
* 有些文件管理的代码迁移过来没删干净,这个忽略一下
*/
namespace app\controllers;
use app\models\UploadForm;
use app\models\User;
use app\utils\FileSizeHelper;
use app\utils\FileTypeDetector;
use Yii;
use yii\base\Exception;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\UploadedFile;
class VaultController extends Controller
{
protected string $pattern = '/^[^\p{C}:*?"<>|\\\\]+$/u';
public function behaviors(): array
{
return array_merge(
parent::behaviors(),
[
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'allow' => true,
'actions' => ['index', 'download', 'delete', 'upload', 'init', 'auth'],
'roles' => ['user'],
],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'index' => ['GET'],
'download' => ['GET'],
'delete' => ['POST'],
'upload' => ['POST'],
'init' => ['POST'],
'auth' => ['POST'],
],
],
]
);
}
/**
* @param null $directory
* @return string|Response
* @throws NotFoundHttpException
*/
public function actionIndex($directory = null): Response|string
{
$model = Yii::$app->user->identity;
if (empty($model->vault_secret)) {
return $this->render('_init', [
'model' => $model,
]);
} elseif (Yii::$app->session->get('vault_auth') !== true) {
return $this->render('_gateway', [
'model' => new User(),
]);
}
$rootDataDirectory = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret';
if ($directory === '.' || $directory == null) {
$directory = null;
$parentDirectory = null;
} elseif (str_contains($directory, '..')) {
throw new NotFoundHttpException('Invalid directory.');
} else {
$parentDirectory = dirname($directory);
}
$directoryContents = $this->getDirectoryContents(join(DIRECTORY_SEPARATOR, [$rootDataDirectory, $directory ?: '.']));
foreach ($directoryContents as $key => $item) {
$relativePath = $directory ? $directory . '/' . $item : $item;
$absolutePath = Yii::getAlias('@app') . '/data/' . Yii::$app->user->id . '.secret/' . $relativePath;
$type = FileTypeDetector::detect($absolutePath);
$lastModified = filemtime($absolutePath);
$size = is_file($absolutePath) ? filesize($absolutePath) : null;
$rawType = is_file($absolutePath) ? mime_content_type($absolutePath) : null;
$directoryContents[$key] = ['name' => $item, 'type' => $type, 'lastModified' => $lastModified, 'size' => $size, 'rawType' => $rawType];
}
$usedSpace = FileSizeHelper::getUserHomeDirSize();
$vaultUsedSpace = FileSizeHelper::getUserVaultDirSize(); // 保险箱已用空间暂时为0
$storageLimit = $model->storage_limit;
return $this->render('index', [
'directoryContents' => $directoryContents,
'parentDirectory' => $parentDirectory,
'directory' => $directory, // 将$directory传递给视图
'usedSpace' => $usedSpace, // B
'vaultUsedSpace' => $vaultUsedSpace, // B
'storageLimit' => $storageLimit, // MB
]);
}
/**
* 获取指定路径下的文件和文件夹内容
* @param string $path 路径
* @return array 文件和文件夹内容数组
* @throws NotFoundHttpException 如果路径不存在
*/
protected function getDirectoryContents(string $path): array
{
// 确定路径是否存在
if (!is_dir($path)) {
throw new NotFoundHttpException('Directory not found.');
}
// 获取路径下的所有文件和文件夹
$directoryContents = scandir($path);
// 移除 '.' 和 '..'
$directoryContents = array_diff($directoryContents, ['.', '..']);
// 使用 usort 对目录内容进行排序,使文件夹始终在文件之前
usort($directoryContents, function ($a, $b) use ($path) {
$aIsDir = is_dir($path . '/' . $a);
$bIsDir = is_dir($path . '/' . $b);
if ($aIsDir === $bIsDir) {
return strnatcasecmp($a, $b); // 如果两者都是文件夹或都是文件,按名称排序
}
return $aIsDir ? -1 : 1; // 文件夹始终在文件之前
});
return $directoryContents;
}
/**
* 下载指定路径下的文件
*
* @param string $relativePath 文件的相对路径
* @throws NotFoundHttpException 如果文件不存在
*/
public function actionDownload(string $relativePath): void
{
// 对相对路径进行解码
$relativePath = rawurldecode($relativePath);
// 检查相对路径是否只包含允许的字符
if (!preg_match($this->pattern, $relativePath) || str_contains($relativePath, '..')) {
throw new NotFoundHttpException('Invalid file path.');
}
// 确定文件的绝对路径
$absolutePath = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret/' . $relativePath;
// 使用realpath函数解析路径并检查解析后的路径是否在预期的目录中
$realPath = realpath($absolutePath);
$dataDirectory = str_replace('/', '\\', Yii::getAlias(Yii::$app->params['dataDirectory']));
if (!$realPath || !str_starts_with($realPath, $dataDirectory)) {
throw new NotFoundHttpException('File not found.');
}
// 检查文件是否存在
if (!file_exists($realPath)) {
throw new NotFoundHttpException('File not found.');
}
// 将文件发送给用户进行下载
Yii::$app->response->sendFile($realPath)->send();
}
/**
* 删除文件
* @throws NotFoundHttpException 如果文件不存在
*/
public function actionDelete(): Response
{
$relativePaths = Yii::$app->request->post('relativePath');
if (!is_array($relativePaths)) {
$relativePaths = [$relativePaths];
}
foreach ($relativePaths as $relativePath) {
$relativePath = rawurldecode($relativePath);
if (!preg_match($this->pattern, $relativePath) || str_contains($relativePath, '..')) {
throw new NotFoundHttpException('Invalid file path.');
}
$absolutePath = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret/' . $relativePath;
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('File or directory not found.');
} else {
$realPath = realpath($absolutePath);
$expectedPathPrefix = realpath(Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret');
if (!str_starts_with($realPath, $expectedPathPrefix)) {
throw new NotFoundHttpException('File or directory not found.');
}
}
if (!unlink($absolutePath)) {
Yii::$app->session->setFlash('error', 'Failed to delete file.');
} else {
Yii::$app->session->setFlash('success', 'File deleted successfully.');
}
}
return $this->redirect(['index', 'directory' => dirname($relativePaths[0])]);
}
/**
* 文件上传
* 注意,已存在的同名文件会被覆盖
*
* @return int|Response
*/
public function actionUpload(): Response|int
{
$model = new UploadForm();
$model->targetDir = Yii::$app->request->post('targetDir', '.');
$uploadedFiles = UploadedFile::getInstancesByName('files');
$successCount = 0;
$totalCount = count($uploadedFiles);
$sp = Yii::$app->request->post('sp', null);
foreach ($uploadedFiles as $uploadedFile) {
$model->uploadFile = $uploadedFile;
if (!preg_match($this->pattern, $model->uploadFile->fullPath) || str_contains($model->uploadFile->fullPath, '..')) {
continue;
}
if (!FileSizeHelper::hasEnoughSpace($model->uploadFile->size)) {
continue;
}
if ($model->upload(1)) {
$successCount++;
}
}
if ($sp === 'editSaving') {
if ($successCount === $totalCount) {
Yii::$app->response->statusCode = 200;
return $this->asJson(['status' => 200, 'message' => '文件上传成功']);
} else {
Yii::$app->response->statusCode = 500;
return $this->asJson(['status' => 500, 'message' => '文件上传失败']);
}
} else {
if ($successCount === $totalCount) {
Yii::$app->session->setFlash('success', '文件上传成功');
} elseif ($successCount > 0) {
Yii::$app->session->setFlash('warning', '部分文件上传失败,这可能是用户剩余空间不足导致');
} else {
Yii::$app->session->setFlash('error', '文件上传失败,这可能是用户剩余空间不足导致');
}
//返回状态码200
return Yii::$app->response->statusCode = 200;
}
}
/**
* 初始化文件保险箱密码
* @return Response
* @throws Exception
*/
public function actionInit(): Response
{
$model = Yii::$app->user->identity; // 获取当前用户模型
if ($model->load(Yii::$app->request->post()) && $model->validate() && !empty($model->input_vault_secret)) {
$model->vault_secret = Yii::$app->getSecurity()->generatePasswordHash($model->input_vault_secret);
if ($model->save(false)) { // 保存用户模型
Yii::$app->session->setFlash('success', '保险箱初始化成功,请牢记密码,否则无法恢复保险箱内文件');
} else {
Yii::$app->session->setFlash('error', '保险箱初始化失败');
}
} else {
Yii::$app->session->setFlash('error', '设定的密码未通过验证');
}
return $this->redirect('index.php?r=vault%2Findex'); // render里面好像只能这么写了
}
/**
* @return Response
*/
public function actionAuth(): Response
{
$user = Yii::$app->user->identity;
$model = new User();
if ($model->load(Yii::$app->request->post()) && !empty($model->vault_secret)) {
if (Yii::$app->getSecurity()->validatePassword($model->vault_secret, $user->vault_secret)) {
Yii::$app->session->set('vault_auth', true);
Yii::$app->session->setFlash('success', '文件保险箱密码验证成功');
return $this->redirect('index.php?r=vault%2Findex');
}
Yii::$app->session->setFlash('error', '保险箱密码错误');
} else {
Yii::$app->session->setFlash('error', '密码未通过验证');
}
return $this->redirect('index.php?r=vault%2Findex');
}
}