diff --git a/controllers/UserController.php b/controllers/UserController.php index 5b5765a..62a143e 100644 --- a/controllers/UserController.php +++ b/controllers/UserController.php @@ -346,6 +346,10 @@ class UserController extends Controller if (!is_dir($userFolder)) { mkdir($userFolder); } + $secretFolder = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . $model->id.'.secret'; + if (!is_dir($secretFolder)) { + mkdir($secretFolder); + } Yii::$app->session->setFlash('success', 'Registration successful. You can now log in.'); return $this->redirect(['login']); } else { @@ -373,8 +377,8 @@ class UserController extends Controller public function actionInfo(string $focus = null): Response|string { $model = Yii::$app->user->identity; - $usedSpace = FileSizeHelper::getDirectorySize(Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id); - $vaultUsedSpace = 0; // 保险箱已用空间,暂时为0 + $usedSpace = FileSizeHelper::getUserHomeDirSize(); + $vaultUsedSpace = FileSizeHelper::getUserVaultDirSize(); $storageLimit = $model->storage_limit; $totp_secret = null; $totp_url = null; diff --git a/controllers/VaultController.php b/controllers/VaultController.php new file mode 100644 index 0000000..c9fd156 --- /dev/null +++ b/controllers/VaultController.php @@ -0,0 +1,243 @@ +|\\\\]+$/u'; + + public function behaviors(): array + { + return array_merge( + parent::behaviors(), + [ + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'allow' => true, + 'actions' => ['index', 'download', 'delete', 'upload'], + 'roles' => ['user'], + ], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'index' => ['GET'], + 'download' => ['GET'], + 'delete' => ['POST'], + 'upload' => ['POST'], + ], + ], + ] + ); + } + + /** + * @param null $directory + * @return string|Response + * @throws NotFoundHttpException + */ + public function actionIndex($directory = null): Response|string + { + $model = Yii::$app->user->identity; + + $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; + } + } +} \ No newline at end of file diff --git a/models/UploadForm.php b/models/UploadForm.php index 02a247e..d15202b 100644 --- a/models/UploadForm.php +++ b/models/UploadForm.php @@ -22,7 +22,7 @@ class UploadForm extends Model ]; } - public function upload(): bool + public function upload(int $is_vault_file = 0): bool { if ($this->validate()) { if ($this->targetDir === null) { @@ -32,6 +32,9 @@ class UploadForm extends Model return false; } $userHomeDir = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id; + if ($is_vault_file == 1) { + $userHomeDir .= '.secret'; + } $absolutePath = $userHomeDir . '/' . $this->targetDir; if (!is_dir($absolutePath)) { return false; diff --git a/views/layouts/main.php b/views/layouts/main.php index 6541128..1b73687 100644 --- a/views/layouts/main.php +++ b/views/layouts/main.php @@ -44,10 +44,10 @@ $darkMode = Yii::$app->user->isGuest ? 0 : Yii::$app->user->identity->dark_mode; 'items' => [ ['label' => '首页', 'url' => ['/site/index']], ['label' => '我的文件', 'url' => ['/home/index']], + ['label' => '文件保险箱', 'url' => ['/vault/index']], ['label' => '分享管理', 'url' => ['/share/index']], ['label' => '文件收集', 'url' => ['/collection/index']], ['label' => '个人设置', 'url' => ['/user/info']], -// ['label' => '系统设置', 'url' => ['/site/contact']], // ['label' => '应用下载', 'url' => ['/site/contact']], Yii::$app->user->isGuest ? ['label' => '登录', 'url' => ['/user/login']] diff --git a/views/vault/index.php b/views/vault/index.php new file mode 100644 index 0000000..79d1a30 --- /dev/null +++ b/views/vault/index.php @@ -0,0 +1,164 @@ +title = '文件保险箱'; +$this->params['breadcrumbs'][] = $this->title; +$totalUsed_F = FileSizeHelper::formatBytes($usedSpace + $vaultUsedSpace); //总已用空间 格式化文本 +$storageLimit_F = FileSizeHelper::formatMegaBytes($storageLimit); //存储限制 格式化文本 +$is_unlimited = ($storageLimit === -1); //检查是否为无限制容量 +$usedPercent = $is_unlimited ? 0 : round($usedSpace / ($storageLimit * 1024 * 1024) * 100); //网盘已用百分比 +$vaultUsedPercent = $is_unlimited ? 0 : round($vaultUsedSpace / ($storageLimit * 1024 * 1024) * 100); //保险箱已用百分比 +$totalUsedPercent = min(($usedPercent + $vaultUsedPercent), 100); //总已用百分比 +$freeSpace = $is_unlimited ? 'unlimited' : ($storageLimit * 1024 * 1024 - $usedSpace - $vaultUsedSpace); //剩余空间 +FontAwesomeAsset::register($this); +JqueryAsset::register($this); +$this->registerCssFile('@web/css/home_style.css'); +?> +
+
+

title) ?>

+
+ 'btn btn-outline-primary single-download-btn']) ?> + 'btn btn-outline-danger batch-delete-btn']) ?> + 'btn btn-outline-primary refresh-btn']) ?> + + +
+
+ + + 0, + 'barOptions' => ['class' => ['bg-success', 'progress-bar-animated', 'progress-bar-striped']], + 'label' => '123', //NMD 不是说可选吗 + 'options' => ['style' => 'display: none;margin-top: 10px;', 'id' => 'progress-bar'] + ]); + ?> + + + + + + + + + + + + + + + + + user->id . '.secret/' . $relativePath; ?> + + + + + + + + + +
名称最近修改时间大小操作
+ + $item['type'] . ' file_icon']) ?> + $relativePath], ['class' => 'file_name']) ?> + + + + formatter->asShortSize($item['size'], 2) : '' ?> + + 'fa-regular fa-circle-down']), [ + 'value' => Url::to(['vault/download', 'relativePath' => $relativePath]), + 'class' => 'btn btn-outline-primary download-btn', + 'data-bs-toggle' => 'tooltip', + 'data-bs-placement' => 'top', + 'data-bs-title' => '下载' + ]) ?> + 'fa-regular fa-trash-can']), ['value' => $relativePath, 'class' => 'btn btn-outline-danger delete-btn', 'data-bs-toggle' => 'tooltip', 'data-bs-placement' => 'top', 'data-bs-title' => '删除']) ?> +
+
+ + '

确认删除

', + 'id' => 'deleteModal', + 'size' => 'modal-sm', +]); + +echo Html::tag('div', '你确定要删除这个文件吗?', ['class' => 'modal-body']); + +echo Html::beginForm(['vault/delete'], 'post', ['id' => 'delete-form']); +echo Html::hiddenInput('relativePath', '', ['id' => 'deleteRelativePath']); +echo Html::submitButton('确认', ['class' => 'btn btn-danger']); +echo Html::endForm(); + +Modal::end(); +$this->registerJsFile('@web/js/vault_script.js', ['depends' => [JqueryAsset::class], 'position' => View::POS_END]); +?> + diff --git a/web/js/vault_script.js b/web/js/vault_script.js new file mode 100644 index 0000000..a40b4d0 --- /dev/null +++ b/web/js/vault_script.js @@ -0,0 +1,201 @@ +var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); +var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl) +}); +$(document).on('click', '.download-btn', function () { + window.location.href = $(this).attr('value'); +}); +$(document).on('click', '.delete-btn', function () { + var relativePath = $(this).attr('value'); + $('#deleteRelativePath').val(relativePath); + $('#deleteModal').modal('show'); +}); +$(document).on('click', '.file-upload-btn', function () { + $('#file-input').click(); +}); +$('#file-input').on('change', function () { + uploadFiles(this.files); +}); +$('#folder-input').on('change', function () { + uploadFiles(this.files); +}); +$(document).on('click', '.refresh-btn', function () { + window.location.reload(); +}); +$(document).on('click', '.single-download-btn', function () { + var relativePath = $('.select-item:checked').first().data('relativePath'); + window.location.href = 'index.php?r=vault%2Fdownload&relativePath=' + encodeURIComponent(relativePath); +}); + +function uploadFiles(files) { + $('#progress-bar').show(); + var formData = new FormData(); + for (var i = 0; i < files.length; i++) { + formData.append('files[]', files[i]); + } + formData.append('targetDir', $('#target-dir').val()); + formData.append('_csrf', $('meta[name="csrf-token"]').attr('content')); + + var xhr = new XMLHttpRequest(); + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + var percentComplete = event.loaded / event.total * 100; + $('#progress-bar .progress-bar').css('width', percentComplete + '%').text(Math.round(percentComplete) + '%'); + } + }; + xhr.onload = function () { + if (xhr.status !== 200) { + alert('An error occurred during the upload.'); + } + window.location.reload(); + }; + xhr.open('POST', 'index.php?r=vault%2Fupload'); + xhr.send(formData); +} + +var dropArea = document.getElementById('drop-area'); +dropArea.addEventListener('dragover', function (event) { + event.preventDefault(); +}); +dropArea.addEventListener('drop', function (event) { + event.preventDefault(); + + var items = event.dataTransfer.items; + var files = []; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + files.push(item.getAsFile()); + } + uploadFiles(files); + dropArea.classList.remove('dragging'); +}); +dropArea.addEventListener('dragenter', function (event) { + event.preventDefault(); + dropArea.classList.add('dragging'); +}); + +dropArea.addEventListener('dragleave', function (event) { + event.preventDefault(); + if (!dropArea.contains(event.relatedTarget)) { + dropArea.classList.remove('dragging'); + } +}); +document.getElementById('select-all').addEventListener('change', function () { + var checkboxes = document.querySelectorAll('.select-item'); + for (var i = 0; i < checkboxes.length; i++) { + checkboxes[i].checked = this.checked; + checkboxes[i].closest('tr').classList.toggle('selected', this.checked); + } +}); +var itemCheckboxes = document.querySelectorAll('.select-item'); +for (var i = 0; i < itemCheckboxes.length; i++) { + itemCheckboxes[i].addEventListener('change', function () { + if (!this.checked) { + document.getElementById('select-all').checked = false; + } else { + var allChecked = true; + for (var j = 0; j < itemCheckboxes.length; j++) { + if (!itemCheckboxes[j].checked) { + allChecked = false; + break; + } + } + document.getElementById('select-all').checked = allChecked; + } + this.closest('tr').classList.toggle('selected', this.checked); + }); +} +document.addEventListener('keydown', function (event) { + if (event.ctrlKey && event.key === 'a') { + event.preventDefault(); + var checkboxes = document.querySelectorAll('.select-item'); + var allChecked = Array.from(checkboxes).every(checkbox => checkbox.checked); + for (var i = 0; i < checkboxes.length; i++) { + checkboxes[i].checked = !allChecked; + checkboxes[i].closest('tr').classList.toggle('selected', !allChecked); + } + updateButtons(); + } + if (event.ctrlKey && event.key === 'd') { + event.preventDefault(); + $('tr.selected').removeClass('selected').find('input[type="checkbox"]').prop('checked', false); + $('#select-all').prop('checked', false); + updateButtons(); + } +}); +$(document).on('click', 'tr', function (event) { + if ($(event.target).is('input[type="checkbox"]') || $(event.target).is('a')) { + return; + } + var checkboxes = document.querySelectorAll('.select-item'); + var checkedCount = Array.from(checkboxes).filter(checkbox => checkbox.checked).length; + var allChecked = checkedCount >= 2; + if (!event.ctrlKey) { + $('tr.selected').not(this).removeClass('selected').find('input[type="checkbox"]').prop('checked', false); + } + if ((!allChecked) || (allChecked && event.ctrlKey)) { + $(this).toggleClass('selected'); + var checkbox = $(this).children(':first-child').find('input[type="checkbox"]'); + checkbox.prop('checked', !checkbox.prop('checked')); + } + updateButtons(); +}); + +function updateButtons() { + var checkboxes = $('.select-item:checked'); + var count = checkboxes.length; + var isSingleFile = count === 1 && !checkboxes.first().data('isDirectory'); + $('.single-download-btn').toggle(isSingleFile); + $('.batch-delete-btn').toggle(count >= 1); +} + +$(document).on('change', '.select-item', updateButtons); +$(document).ready(function () { + updateButtons(); + $('tr').contextmenu(function (e) { + e.preventDefault(); + if ($(e.target).is('button') || $(e.target).is('i') || $(e.target).is('a')) { + e.preventDefault(); + return; + } + var clickedElement = $(this); + if (!clickedElement.hasClass('selected')) { + $('tr.selected').removeClass('selected').find('input[type="checkbox"]').prop('checked', false); + clickedElement.addClass('selected'); + var checkbox = clickedElement.children(':first-child').find('input[type="checkbox"]'); + checkbox.prop('checked', true); + updateButtons(); + } + $('#option-download').toggle($('.single-download-btn').css('display') !== 'none'); + $('#option-batch-delete').toggle($('.batch-delete-btn').css('display') !== 'none'); + $('#option-refresh').toggle($('.refresh-btn').css('display') !== 'none'); + $('#contextMenu').css({ + display: "block", + left: e.pageX, + top: e.pageY + }).addClass('show'); + $('#contextMenu .dropdown-menu').addClass('show'); + }); + $('#contextMenu a').off('click').on('click', function (e) { + e.preventDefault(); + var clickedMenuItem = $(this).attr('id'); + switch (clickedMenuItem) { + case 'option-download': + $('.single-download-btn').click(); + break; + case 'option-batch-delete': + $('.batch-delete-btn').click(); + break; + case 'option-refresh': + $('.refresh-btn').click(); + break; + } + $('#contextMenu').hide().removeClass('show'); + }); + $(document).click(function () { + $('#contextMenu').hide().removeClass('show'); + $('#contextMenu .dropdown-menu').removeClass('show'); + }); +}); +const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]') +const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)) \ No newline at end of file