diff --git a/CHANGELOG.md b/CHANGELOG.md index 77666aa..2196226 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,15 @@ - ユーザーごとのスケジュール表示色設定を追加 - ユーザー作成時に重複しにくい色を自動割り当て - ユーザー編集画面から色変更可能 +- ファイル共有リンク機能を追加 + - 期限付きリンク / パスワード保護 / ダウンロード回数上限 + - 共有先のユーザー / 組織を指定したアクセス制御 + - 共有先への通知配信(メールキュー連携) + - 発行済みリンクの無効化 +- 管理設定にファイル共有ガバナンスを追加 + - 1ファイル上限容量 + - 全体・ユーザー・組織の容量上限 + - 共有リンクの既定有効日数 ### Changed / 変更 @@ -40,6 +49,7 @@ - アプリバージョンを `v0.9.0-beta.6` に更新 - スケジュール画面(日/週/月・組織週/月)の配色とカード視認性を改善 - 月間ビュー(個人/組織)をモバイル全幅で表示し、予定カードのタップ視認性を改善 +- ファイルアップロード画面に容量ガイド(上限値・使用量)を表示 ### Fixed / 修正 diff --git a/Controllers/FileManagerController.php b/Controllers/FileManagerController.php index 453960e..4aa25f7 100755 --- a/Controllers/FileManagerController.php +++ b/Controllers/FileManagerController.php @@ -6,6 +6,7 @@ use Core\Database; use Models\Notification; use Models\Organization; +use Models\Setting; use Models\User; use Services\FileDiffService; use Services\FilePermissionService; @@ -19,6 +20,7 @@ class FileManagerController extends Controller private $userModel; private $organizationModel; private $permissionService; + private $settingModel; public function __construct() { @@ -29,6 +31,7 @@ public function __construct() $this->userModel = new User(); $this->organizationModel = new Organization(); $this->permissionService = new FilePermissionService($this->db); + $this->settingModel = new Setting(); if (!$this->auth->check()) { $this->redirect(BASE_PATH . '/login'); @@ -400,6 +403,8 @@ public function upload() 'csrf_token' => $this->generateCsrfToken(), 'organizations' => $this->organizationModel->getAll(), 'users' => $this->userModel->getActiveUsers(), + 'fileShareLimits' => $this->getFileShareLimits(), + 'storageUsage' => $this->getCurrentStorageUsage($currentUser), ]); } @@ -442,6 +447,12 @@ public function storeFile() $originalName = $file['name']; $mimeType = $file['type']; $fileSize = $file['size']; + $quotaError = $this->validateStorageConstraints((int)$fileSize, $currentUser); + if ($quotaError !== null) { + $_SESSION['flash_error'] = $quotaError; + $this->redirect(BASE_PATH . '/files/upload' . ($folderId ? '?folder_id=' . $folderId : '')); + return; + } // ユニークファイル名生成 $extension = pathinfo($originalName, PATHINFO_EXTENSION); @@ -528,6 +539,7 @@ public function showFile($params) $approvalRequests = $this->getApprovalRequests($fileId); $activeApprovalRequest = $approvalRequests[0] ?? null; $comparison = $this->buildVersionComparison($versions); + $shareLinks = $this->getShareLinksForFile($fileId); $this->view('file_manager/show', [ 'title' => htmlspecialchars($file['title']) . ' - ファイル管理', @@ -545,6 +557,8 @@ public function showFile($params) 'approvalRequests' => $approvalRequests, 'activeApprovalRequest' => $activeApprovalRequest, 'comparison' => $comparison, + 'shareLinks' => $shareLinks, + 'fileShareLimits' => $this->getFileShareLimits(), ]); } @@ -662,6 +676,12 @@ public function updateFile($params) $originalName = $uploadedFile['name']; $mimeType = $uploadedFile['type']; $fileSize = $uploadedFile['size']; + $quotaError = $this->validateStorageConstraints((int)$fileSize, $this->auth->user()); + if ($quotaError !== null) { + $_SESSION['flash_error'] = $quotaError; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } $extension = pathinfo($originalName, PATHINFO_EXTENSION); $storedName = uniqid('file_', true) . ($extension ? '.' . $extension : ''); @@ -777,6 +797,143 @@ public function updatePermissions($params) $this->redirect(BASE_PATH . '/files/file/' . $fileId); } + public function createShareLink($params) + { + $fileId = (int)$params['id']; + + if (!$this->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $_SESSION['flash_error'] = '不正なリクエストです。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } + + $file = $this->db->fetch("SELECT * FROM file_entries WHERE id = ?", [$fileId]); + if (!$file || !$this->permissionService->canAdminFile($file, $this->auth->user())) { + $_SESSION['flash_error'] = '共有リンクを作成する権限がありません。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } + + $expiresAtInput = trim((string)($_POST['expires_at'] ?? '')); + $sharePassword = trim((string)($_POST['share_password'] ?? '')); + $maxDownloads = (int)($_POST['max_downloads'] ?? 0); + $shareUserIds = $this->permissionService->normalizeIds($_POST['share_user_ids'] ?? []); + $shareOrgIds = $this->permissionService->normalizeIds($_POST['share_organization_ids'] ?? []); + $notifyRecipients = ((string)($_POST['notify_recipients'] ?? '1') === '1'); + + $expiresAt = null; + if ($expiresAtInput !== '') { + $ts = strtotime($expiresAtInput); + if ($ts === false) { + $_SESSION['flash_error'] = '有効期限の形式が不正です。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } + if ($ts <= time()) { + $_SESSION['flash_error'] = '有効期限は現在時刻より後を指定してください。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } + $expiresAt = date('Y-m-d H:i:s', $ts); + } else { + $defaultExpiryDays = (int)$this->settingModel->get('files_share_default_expiry_days', '7'); + if ($defaultExpiryDays > 0) { + $expiresAt = date('Y-m-d H:i:s', strtotime('+' . $defaultExpiryDays . ' days')); + } + } + + if ($maxDownloads <= 0) { + $maxDownloads = null; + } + + $passwordHash = $sharePassword !== '' ? password_hash($sharePassword, PASSWORD_DEFAULT) : null; + try { + $token = $this->generateShareToken(); + } catch (\Throwable $e) { + error_log('generateShareToken error: ' . $e->getMessage()); + $_SESSION['flash_error'] = '共有リンクの作成に失敗しました。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } + + $this->db->beginTransaction(); + try { + $this->db->execute( + "INSERT INTO file_share_links (file_id, token, created_by, expires_at, password_hash, max_downloads) + VALUES (?, ?, ?, ?, ?, ?)", + [$fileId, $token, $this->auth->id(), $expiresAt, $passwordHash, $maxDownloads] + ); + $shareLinkId = (int)$this->db->lastInsertId(); + + foreach ($shareUserIds as $targetUserId) { + $this->db->execute( + "INSERT INTO file_share_targets (share_link_id, target_type, target_id) VALUES (?, 'user', ?)", + [$shareLinkId, (int)$targetUserId] + ); + } + foreach ($shareOrgIds as $targetOrgId) { + $this->db->execute( + "INSERT INTO file_share_targets (share_link_id, target_type, target_id) VALUES (?, 'organization', ?)", + [$shareLinkId, (int)$targetOrgId] + ); + } + $this->db->commit(); + } catch (\Exception $e) { + $this->db->rollBack(); + error_log('createShareLink error: ' . $e->getMessage()); + $_SESSION['flash_error'] = '共有リンクの作成に失敗しました。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + return; + } + + if ($notifyRecipients) { + $this->notifyShareRecipients($file, $token, $shareUserIds, $shareOrgIds, $expiresAt); + } + + $_SESSION['flash_success'] = '共有リンクを作成しました。'; + $this->redirect(BASE_PATH . '/files/file/' . $fileId); + } + + public function revokeShareLink($params) + { + $shareId = (int)$params['id']; + + if (!$this->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $_SESSION['flash_error'] = '不正なリクエストです。'; + $this->redirect(BASE_PATH . '/files'); + return; + } + + $share = $this->db->fetch( + "SELECT fsl.*, fe.id AS file_id + FROM file_share_links fsl + INNER JOIN file_entries fe ON fe.id = fsl.file_id + WHERE fsl.id = ?", + [$shareId] + ); + + if (!$share) { + $_SESSION['flash_error'] = '共有リンクが見つかりません。'; + $this->redirect(BASE_PATH . '/files'); + return; + } + + $file = $this->db->fetch("SELECT * FROM file_entries WHERE id = ?", [(int)$share['file_id']]); + if (!$file || !$this->permissionService->canAdminFile($file, $this->auth->user())) { + $_SESSION['flash_error'] = '共有リンクを無効化する権限がありません。'; + $this->redirect(BASE_PATH . '/files/file/' . (int)$share['file_id']); + return; + } + + $this->db->execute( + "UPDATE file_share_links SET revoked_at = NOW() WHERE id = ?", + [$shareId] + ); + + $_SESSION['flash_success'] = '共有リンクを無効化しました。'; + $this->redirect(BASE_PATH . '/files/file/' . (int)$share['file_id']); + } + public function checkoutFile($params) { $fileId = (int)$params['id']; @@ -1248,6 +1405,235 @@ private function getRecentActivities($folderId = null, $limit = 8) })); } + private function getFileShareLimits() + { + return [ + 'max_upload_mb' => max(0, (int)$this->settingModel->get('files_max_upload_mb', '512')), + 'storage_quota_mb' => max(0, (int)$this->settingModel->get('files_storage_quota_mb', '10240')), + 'user_quota_mb' => max(0, (int)$this->settingModel->get('files_user_quota_mb', '2048')), + 'org_quota_mb' => max(0, (int)$this->settingModel->get('files_org_quota_mb', '5120')), + 'share_default_expiry_days' => max(0, (int)$this->settingModel->get('files_share_default_expiry_days', '7')), + ]; + } + + private function getCurrentStorageUsage($currentUser = null) + { + $usage = [ + 'total_bytes' => 0, + 'user_bytes' => 0, + 'org_bytes' => 0, + ]; + + try { + $total = $this->db->fetch("SELECT COALESCE(SUM(file_size), 0) AS bytes FROM file_versions"); + $usage['total_bytes'] = (int)($total['bytes'] ?? 0); + + if (!empty($currentUser['id'])) { + $userUsage = $this->db->fetch( + "SELECT COALESCE(SUM(file_size), 0) AS bytes FROM file_versions WHERE uploaded_by = ?", + [(int)$currentUser['id']] + ); + $usage['user_bytes'] = (int)($userUsage['bytes'] ?? 0); + } + + $orgId = (int)($currentUser['organization_id'] ?? 0); + if ($orgId > 0) { + $orgUsage = $this->db->fetch( + "SELECT COALESCE(SUM(t.file_size), 0) AS bytes + FROM ( + SELECT DISTINCT fv.id, fv.file_size + FROM file_versions fv + INNER JOIN users u ON u.id = fv.uploaded_by + LEFT JOIN user_organizations uo ON uo.user_id = u.id + WHERE u.organization_id = ? OR uo.organization_id = ? + ) t", + [$orgId, $orgId] + ); + $usage['org_bytes'] = (int)($orgUsage['bytes'] ?? 0); + } + } catch (\Throwable $e) { + error_log('getCurrentStorageUsage error: ' . $e->getMessage()); + } + + return $usage; + } + + private function validateStorageConstraints($incomingBytes, $currentUser) + { + $incomingBytes = (int)$incomingBytes; + if ($incomingBytes <= 0) { + return 'ファイルサイズが不正です。'; + } + + $limits = $this->getFileShareLimits(); + $usage = $this->getCurrentStorageUsage($currentUser); + + $maxUploadBytes = $this->mbToBytes($limits['max_upload_mb']); + if ($maxUploadBytes > 0 && $incomingBytes > $maxUploadBytes) { + return '1ファイルの上限容量を超えています。管理者にご確認ください。'; + } + + $totalQuotaBytes = $this->mbToBytes($limits['storage_quota_mb']); + if ($totalQuotaBytes > 0 && ($usage['total_bytes'] + $incomingBytes) > $totalQuotaBytes) { + return 'ファイル共有の全体容量上限を超過するためアップロードできません。'; + } + + $userQuotaBytes = $this->mbToBytes($limits['user_quota_mb']); + if ($userQuotaBytes > 0 && ($usage['user_bytes'] + $incomingBytes) > $userQuotaBytes) { + return 'ユーザー容量上限を超過するためアップロードできません。'; + } + + $orgQuotaBytes = $this->mbToBytes($limits['org_quota_mb']); + if ($orgQuotaBytes > 0 && ($usage['org_bytes'] + $incomingBytes) > $orgQuotaBytes) { + return '組織容量上限を超過するためアップロードできません。'; + } + + return null; + } + + private function mbToBytes($mb) + { + $mb = (int)$mb; + if ($mb <= 0) { + return 0; + } + + return $mb * 1024 * 1024; + } + + private function generateShareToken() + { + for ($i = 0; $i < 10; $i++) { + $token = bin2hex(random_bytes(24)); + $exists = $this->db->fetch( + "SELECT id FROM file_share_links WHERE token = ? LIMIT 1", + [$token] + ); + if (empty($exists)) { + return $token; + } + } + + throw new \RuntimeException('共有トークンの生成に失敗しました。'); + } + + private function getShareLinksForFile($fileId) + { + try { + $links = $this->db->fetchAll( + "SELECT * + FROM file_share_links + WHERE file_id = ? + ORDER BY created_at DESC", + [(int)$fileId] + ); + } catch (\Throwable $e) { + error_log('getShareLinksForFile error: ' . $e->getMessage()); + return []; + } + + foreach ($links as &$link) { + try { + $targets = $this->db->fetchAll( + "SELECT fst.target_type, fst.target_id, u.display_name AS user_name, o.name AS organization_name + FROM file_share_targets fst + LEFT JOIN users u ON fst.target_type = 'user' AND u.id = fst.target_id + LEFT JOIN organizations o ON fst.target_type = 'organization' AND o.id = fst.target_id + WHERE fst.share_link_id = ? + ORDER BY fst.id ASC", + [(int)$link['id']] + ); + } catch (\Throwable $e) { + error_log('getShareLinksForFile targets error: ' . $e->getMessage()); + $targets = []; + } + + $targetUsers = []; + $targetOrganizations = []; + foreach ($targets as $target) { + if ($target['target_type'] === 'user' && !empty($target['user_name'])) { + $targetUsers[] = $target['user_name']; + } + if ($target['target_type'] === 'organization' && !empty($target['organization_name'])) { + $targetOrganizations[] = $target['organization_name']; + } + } + + $link['target_users'] = $targetUsers; + $link['target_organizations'] = $targetOrganizations; + $link['has_password'] = !empty($link['password_hash']); + $link['is_revoked'] = !empty($link['revoked_at']); + $link['is_expired'] = !empty($link['expires_at']) && strtotime((string)$link['expires_at']) < time(); + $link['is_download_limited'] = !is_null($link['max_downloads']) && (int)$link['max_downloads'] > 0; + $link['is_download_limit_reached'] = $link['is_download_limited'] && (int)$link['download_count'] >= (int)$link['max_downloads']; + } + + return $links; + } + + private function notifyShareRecipients(array $file, $token, array $targetUserIds, array $targetOrgIds, $expiresAt) + { + $recipientIds = $this->collectShareRecipientUserIds($targetUserIds, $targetOrgIds); + if (empty($recipientIds)) { + return; + } + + $actor = $this->auth->user(); + $title = 'ファイル共有リンク'; + $content = ($actor['display_name'] ?? 'ユーザー') . ' さんが「' . ($file['title'] ?? 'ファイル') . '」の共有リンクを発行しました。'; + if (!empty($expiresAt)) { + $content .= "\n有効期限: " . date('Y/m/d H:i', strtotime((string)$expiresAt)); + } + + foreach (NotificationRecipientHelper::uniqueRecipients($recipientIds, []) as $recipientId) { + $this->notification->create([ + 'user_id' => (int)$recipientId, + 'type' => 'system', + 'title' => $title, + 'content' => $content, + 'link' => '/files/share/' . $token, + 'reference_id' => (int)$file['id'], + 'reference_type' => 'file_share' + ]); + } + } + + private function collectShareRecipientUserIds(array $targetUserIds, array $targetOrgIds) + { + $recipientIds = []; + + foreach ($targetUserIds as $id) { + $id = (int)$id; + if ($id > 0) { + $recipientIds[$id] = $id; + } + } + + foreach ($targetOrgIds as $organizationId) { + $organizationId = (int)$organizationId; + if ($organizationId <= 0) { + continue; + } + + $rows = $this->db->fetchAll( + "SELECT DISTINCT u.id + FROM users u + LEFT JOIN user_organizations uo ON uo.user_id = u.id + WHERE u.status = 'active' + AND (u.organization_id = ? OR uo.organization_id = ?)", + [$organizationId, $organizationId] + ); + foreach ($rows as $row) { + $userId = (int)($row['id'] ?? 0); + if ($userId > 0) { + $recipientIds[$userId] = $userId; + } + } + } + + return array_values($recipientIds); + } + private function notifyFileActivity($action, $fileId, array $fileData) { $actor = $this->auth->user(); diff --git a/Controllers/FileShareController.php b/Controllers/FileShareController.php new file mode 100644 index 0000000..eaf9912 --- /dev/null +++ b/Controllers/FileShareController.php @@ -0,0 +1,261 @@ +db = Database::getInstance(); + $this->uploadDir = __DIR__ . '/../uploads/files/'; + } + + public function access($params) + { + $token = trim((string)($params['token'] ?? '')); + if ($token === '') { + http_response_code(404); + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => 'not_found', + 'message' => tr_text('共有リンクが見つかりません。', 'Share link was not found.'), + ]); + return; + } + + $share = $this->db->fetch( + "SELECT fsl.*, fe.title, fe.original_name, fe.filename, fe.mime_type, fe.file_size + FROM file_share_links fsl + INNER JOIN file_entries fe ON fe.id = fsl.file_id + WHERE fsl.token = ? + LIMIT 1", + [$token] + ); + + if (!$share) { + http_response_code(404); + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => 'not_found', + 'message' => tr_text('共有リンクが見つかりません。', 'Share link was not found.'), + ]); + return; + } + + $status = $this->validateShareStatus($share); + if ($status !== 'ok') { + http_response_code(410); + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => $status, + 'share' => $share, + 'message' => $this->statusMessage($status), + ]); + return; + } + + $targets = $this->db->fetchAll( + "SELECT target_type, target_id FROM file_share_targets WHERE share_link_id = ?", + [(int)$share['id']] + ); + $targetCheck = $this->validateTargetAccess($targets); + if ($targetCheck !== 'ok') { + $httpCode = $targetCheck === 'login_required' ? 401 : 403; + http_response_code($httpCode); + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => $targetCheck, + 'share' => $share, + 'message' => $this->statusMessage($targetCheck), + ]); + return; + } + + $passwordError = ''; + if (!empty($share['password_hash']) && !$this->isSharePasswordVerified($token)) { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $password = (string)($_POST['share_password'] ?? ''); + if ($password !== '' && password_verify($password, (string)$share['password_hash'])) { + $this->markSharePasswordVerified($token); + $this->redirect(BASE_PATH . '/files/share/' . urlencode($token)); + return; + } + $passwordError = tr_text('パスワードが一致しません。', 'Password is incorrect.'); + } + + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => 'password_required', + 'share' => $share, + 'message' => tr_text('このリンクはパスワード保護されています。', 'This share link is password protected.'), + 'passwordError' => $passwordError, + ]); + return; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['download'] ?? '') === '1') { + $this->streamSharedFile($share); + return; + } + + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有ファイル', 'Shared file'), + 'status' => 'ready', + 'share' => $share, + 'message' => tr_text('共有ファイルをダウンロードできます。', 'This shared file is ready to download.'), + ]); + } + + private function validateShareStatus(array $share) + { + if (!empty($share['revoked_at'])) { + return 'revoked'; + } + if (!empty($share['expires_at']) && strtotime((string)$share['expires_at']) < time()) { + return 'expired'; + } + if (!is_null($share['max_downloads']) && (int)$share['max_downloads'] > 0 && (int)$share['download_count'] >= (int)$share['max_downloads']) { + return 'download_limit'; + } + return 'ok'; + } + + private function validateTargetAccess(array $targets) + { + if (empty($targets)) { + return 'ok'; + } + + $user = $this->auth->user(); + if (!$user) { + return 'login_required'; + } + + $userId = (int)$user['id']; + $userOrgIds = $this->getUserOrganizationIds($userId, (int)($user['organization_id'] ?? 0)); + + foreach ($targets as $target) { + $targetType = (string)($target['target_type'] ?? ''); + $targetId = (int)($target['target_id'] ?? 0); + if ($targetType === 'user' && $targetId === $userId) { + return 'ok'; + } + if ($targetType === 'organization' && in_array($targetId, $userOrgIds, true)) { + return 'ok'; + } + } + + return 'forbidden'; + } + + private function getUserOrganizationIds($userId, $primaryOrgId) + { + $orgIds = []; + if ($primaryOrgId > 0) { + $orgIds[$primaryOrgId] = $primaryOrgId; + } + + $rows = $this->db->fetchAll( + "SELECT organization_id FROM user_organizations WHERE user_id = ?", + [(int)$userId] + ); + foreach ($rows as $row) { + $orgId = (int)($row['organization_id'] ?? 0); + if ($orgId > 0) { + $orgIds[$orgId] = $orgId; + } + } + + return array_values($orgIds); + } + + private function isSharePasswordVerified($token) + { + return !empty($_SESSION['file_share_verified_' . $token]); + } + + private function markSharePasswordVerified($token) + { + $_SESSION['file_share_verified_' . $token] = 1; + } + + private function statusMessage($status) + { + switch ($status) { + case 'revoked': + return tr_text('この共有リンクは無効化されています。', 'This share link has been revoked.'); + case 'expired': + return tr_text('この共有リンクの有効期限は終了しました。', 'This share link has expired.'); + case 'download_limit': + return tr_text('この共有リンクのダウンロード上限に達しました。', 'This share link reached the download limit.'); + case 'login_required': + return tr_text('この共有リンクはログインが必要です。', 'This share link requires sign-in.'); + case 'forbidden': + return tr_text('この共有リンクにアクセスする権限がありません。', 'You do not have permission to access this share link.'); + default: + return tr_text('共有リンクが見つかりません。', 'Share link was not found.'); + } + } + + private function streamSharedFile(array $share) + { + $status = $this->validateShareStatus($share); + if ($status !== 'ok') { + http_response_code(410); + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => $status, + 'share' => $share, + 'message' => $this->statusMessage($status), + ]); + return; + } + + $filePath = $this->uploadDir . (string)$share['filename']; + if (!is_file($filePath)) { + http_response_code(404); + $this->view('file_manager/shared_download', [ + 'title' => tr_text('共有リンク', 'Shared link'), + 'status' => 'not_found', + 'share' => $share, + 'message' => tr_text('ファイルが見つかりません。', 'The file could not be found.'), + ]); + return; + } + + $this->db->beginTransaction(); + try { + $this->db->execute( + "UPDATE file_share_links SET download_count = download_count + 1 WHERE id = ?", + [(int)$share['id']] + ); + $this->db->execute( + "UPDATE file_entries SET download_count = download_count + 1 WHERE id = ?", + [(int)$share['file_id']] + ); + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + error_log('streamSharedFile counter update error: ' . $e->getMessage()); + } + + $mimeType = (string)($share['mime_type'] ?? ''); + if ($mimeType === '') { + $mimeType = 'application/octet-stream'; + } + $downloadName = (string)($share['original_name'] ?? 'download.bin'); + + header('Content-Type: ' . $mimeType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('Content-Length: ' . filesize($filePath)); + header('Cache-Control: no-cache, must-revalidate'); + readfile($filePath); + exit; + } +} diff --git a/Controllers/SettingController.php b/Controllers/SettingController.php index c80e53c..ce07680 100755 --- a/Controllers/SettingController.php +++ b/Controllers/SettingController.php @@ -129,6 +129,11 @@ public function security() 'security_login_window_minutes' => '15', 'security_admin_ip_restriction_enabled' => '0', 'security_admin_ip_allowlist' => '', + 'files_max_upload_mb' => '512', + 'files_storage_quota_mb' => '10240', + 'files_user_quota_mb' => '2048', + 'files_org_quota_mb' => '5120', + 'files_share_default_expiry_days' => '7', ], $settingsArray); // Pushが有効な場合は公開鍵を生成/表示できるようにする @@ -297,6 +302,23 @@ private function normalizeSettingValue($key, $value) } return (string)$minutes; + case 'files_max_upload_mb': + case 'files_storage_quota_mb': + case 'files_user_quota_mb': + case 'files_org_quota_mb': + $megaBytes = (int)$stringValue; + if ($megaBytes < 0 || $megaBytes > 1048576) { + throw new \InvalidArgumentException(tr_text('ファイル容量設定は 0-1048576 MB の範囲で指定してください。', 'File storage values must be between 0 and 1048576 MB.')); + } + return (string)$megaBytes; + + case 'files_share_default_expiry_days': + $days = (int)$stringValue; + if ($days < 0 || $days > 3650) { + throw new \InvalidArgumentException(tr_text('共有リンク既定有効日数は 0-3650 日の範囲で指定してください。', 'Default share-link expiry days must be between 0 and 3650.')); + } + return (string)$days; + case 'smtp_auth': case 'smtp_allow_self_signed': case 'notification_enabled': diff --git a/README.md b/README.md index 97d3155..0905e70 100755 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ - バージョン管理(チェックアウト / チェックイン) - ファイル単位のアクセス権限設定 - 承認リクエスト機能 +- 共有リンク発行(有効期限 / パスワード / ダウンロード回数上限) +- 共有先のユーザー / 組織を指定した配布(通知・メールキュー連携) +- 発行済み共有リンクの即時無効化 +- 管理者による容量ガバナンス(1ファイル上限 / 全体 / ユーザー / 組織) ### 🗄️ WEBデータベース - 独自フィールド定義によるカスタムデータ管理 @@ -376,6 +380,7 @@ mysql -u -p < db/upgrade_20260327_daily_report_structured.sql mysql -u -p < db/upgrade_20260328_daily_report_advanced.sql mysql -u -p < db/upgrade_20260328_fk_stability.sql mysql -u -p < db/upgrade_20260328_webdatabase_nocode.sql +mysql -u -p < db/upgrade_20260401_file_sharing.sql ``` 適用前には必ず DB バックアップを取得してください。 diff --git a/db/schema.sql b/db/schema.sql index cc1f225..e5f10e5 100755 --- a/db/schema.sql +++ b/db/schema.sql @@ -1060,6 +1060,11 @@ VALUES ('security_login_window_minutes', '15', 'ログイン失敗集計分'), ('security_admin_ip_restriction_enabled', '0', '管理者設定へのIP制限有効化'), ('security_admin_ip_allowlist', '', '管理者設定許可IP/CIDRリスト'), + ('files_max_upload_mb', '512', 'ファイル共有の1ファイル上限(MB)'), + ('files_storage_quota_mb', '10240', 'ファイル共有の全体容量上限(MB)'), + ('files_user_quota_mb', '2048', 'ファイル共有のユーザー別容量上限(MB)'), + ('files_org_quota_mb', '5120', 'ファイル共有の組織別容量上限(MB)'), + ('files_share_default_expiry_days', '7', '共有リンクの既定有効日数'), ('oidc_enabled', '0', 'OIDC有効化'), ('oidc_issuer', '', 'OIDC Issuer URL'), ('oidc_client_id', '', 'OIDC Client ID'), diff --git a/db/upgrade_20260401_file_sharing.sql b/db/upgrade_20260401_file_sharing.sql new file mode 100644 index 0000000..d282209 --- /dev/null +++ b/db/upgrade_20260401_file_sharing.sql @@ -0,0 +1,40 @@ +-- File sharing links and storage governance settings +-- 2026-04-01 + +CREATE TABLE IF NOT EXISTS file_share_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + file_id INT NOT NULL, + token CHAR(48) NOT NULL, + created_by INT NULL, + expires_at DATETIME NULL, + password_hash VARCHAR(255) NULL, + max_downloads INT NULL, + download_count INT NOT NULL DEFAULT 0, + revoked_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_file_share_links_token (token), + INDEX idx_file_share_links_file (file_id), + INDEX idx_file_share_links_expires (expires_at), + CONSTRAINT fk_file_share_links_file FOREIGN KEY (file_id) REFERENCES file_entries(id) ON DELETE CASCADE, + CONSTRAINT fk_file_share_links_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS file_share_targets ( + id INT AUTO_INCREMENT PRIMARY KEY, + share_link_id INT NOT NULL, + target_type ENUM('user', 'organization') NOT NULL, + target_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_file_share_targets (share_link_id, target_type, target_id), + INDEX idx_file_share_targets_target (target_type, target_id), + CONSTRAINT fk_file_share_targets_link FOREIGN KEY (share_link_id) REFERENCES file_share_links(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO settings (setting_key, setting_value, description) VALUES + ('files_max_upload_mb', '512', 'ファイル共有の1ファイル上限(MB)'), + ('files_storage_quota_mb', '10240', 'ファイル共有の全体容量上限(MB)'), + ('files_user_quota_mb', '2048', 'ファイル共有のユーザー別容量上限(MB)'), + ('files_org_quota_mb', '5120', 'ファイル共有の組織別容量上限(MB)'), + ('files_share_default_expiry_days', '7', '共有リンクの既定有効日数') +ON DUPLICATE KEY UPDATE setting_value = setting_value; diff --git a/public/index.php b/public/index.php index 0edf821..57da05d 100755 --- a/public/index.php +++ b/public/index.php @@ -1939,6 +1939,16 @@ $controller->download($params); }); +$router->get('/files/share/:token', function ($params) { + $controller = new Controllers\FileShareController(); + $controller->access($params); +}); + +$router->post('/files/share/:token', function ($params) { + $controller = new Controllers\FileShareController(); + $controller->access($params); +}); + $router->post('/files/file/:id/update', function ($params) { $controller = new Controllers\FileManagerController(); $controller->updateFile($params); @@ -1949,6 +1959,16 @@ $controller->updatePermissions($params); }); +$router->post('/files/file/:id/share-links', function ($params) { + $controller = new Controllers\FileManagerController(); + $controller->createShareLink($params); +}); + +$router->post('/files/share/:id/revoke', function ($params) { + $controller = new Controllers\FileManagerController(); + $controller->revokeShareLink($params); +}); + $router->post('/files/file/:id/checkout', function ($params) { $controller = new Controllers\FileManagerController(); $controller->checkoutFile($params); diff --git a/public/js/setting.js b/public/js/setting.js index 29e2230..cf96993 100755 --- a/public/js/setting.js +++ b/public/js/setting.js @@ -446,6 +446,18 @@ document.addEventListener('DOMContentLoaded', function () { }); } + const fileShareSettingsForm = document.getElementById('fileShareSettingsForm'); + if (fileShareSettingsForm) { + fileShareSettingsForm.addEventListener('submit', function (e) { + e.preventDefault(); + saveSettingsForm( + fileShareSettingsForm, + document.getElementById('fileShareSuccessAlert'), + document.getElementById('fileShareErrorAlert') + ); + }); + } + const scimSettingsForm = document.getElementById('scimSettingsForm'); if (scimSettingsForm) { scimSettingsForm.addEventListener('submit', function (e) { diff --git a/views/file_manager/shared_download.php b/views/file_manager/shared_download.php new file mode 100644 index 0000000..f648380 --- /dev/null +++ b/views/file_manager/shared_download.php @@ -0,0 +1,57 @@ + +
+
+
+

+ +

+

+ + +
+
:
+
: bytes
+ +
:
+ + 0): ?> +
: /
+ +
+ +
+ + +
+ + +
+ + +
+
:
+
+ +
+ + +
+ + + + + + + + + +
+
+
diff --git a/views/file_manager/show.php b/views/file_manager/show.php index f45f98e..a53ea88 100755 --- a/views/file_manager/show.php +++ b/views/file_manager/show.php @@ -7,6 +7,17 @@ 'approve' => ['organizations' => [], 'users' => []], 'admin' => ['organizations' => [], 'users' => []], ]; +$shareLinks = $shareLinks ?? []; +$fileShareLimits = $fileShareLimits ?? [ + 'share_default_expiry_days' => 7, +]; +$defaultShareExpiryValue = ''; +if ((int)($fileShareLimits['share_default_expiry_days'] ?? 0) > 0) { + $defaultShareExpiryValue = date('Y-m-d\TH:i', strtotime('+' . (int)$fileShareLimits['share_default_expiry_days'] . ' days')); +} +$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$shareBaseUrl = rtrim($scheme . '://' . $host . BASE_PATH, '/'); ?> + +
+
+
共有リンク
+
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
有効な共有リンクはまだありません。
+ +
+ + +
+
+ + +
+
+
+ DL: + 0): ?> + / + + + ・期限: + + + ・パスワード保護 + +
+ +
+ + 組織: + + + / + ユーザー: + +
+ +
+ + +
+ + +
+ +
+
+ +
+ +
+
+ +
diff --git a/views/file_manager/upload.php b/views/file_manager/upload.php index 7f718cf..7cdb72d 100755 --- a/views/file_manager/upload.php +++ b/views/file_manager/upload.php @@ -2,6 +2,17 @@ 0, 'user_bytes' => 0, 'org_bytes' => 0]; +if (!function_exists('fmFormatBytes')) { + function fmFormatBytes($bytes) { + $bytes = (int)$bytes; + if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2) . ' GB'; + if ($bytes >= 1048576) return number_format($bytes / 1048576, 2) . ' MB'; + if ($bytes >= 1024) return number_format($bytes / 1024, 2) . ' KB'; + return $bytes . ' B'; + } +} ?>
@@ -32,6 +43,17 @@ +
+
容量ガイド
+
+ 1ファイル上限: 0 ? (int)$fileShareLimits['max_upload_mb'] . ' MB' : '無制限' ?> / + 全体使用量: + 0): ?> + (上限 MB) + +
+
+
diff --git a/views/help/index.php b/views/help/index.php index 73ce48d..779c391 100755 --- a/views/help/index.php +++ b/views/help/index.php @@ -598,6 +598,16 @@

アクセス権限

フォルダやファイルには、閲覧・編集が可能なメンバーや組織を設定することができます。機密資料の管理にご活用ください。

+ +

共有リンク(期限・パスワード・対象指定)

+
1ファイル詳細画面の「共有リンク」から発行します。
+
2必要に応じて有効期限、ダウンロード回数上限、共有パスワードを設定します。
+
3共有先のユーザー/組織を選択すると、通知(メールキュー連携)でリンク配信できます。
+
4発行済みリンクは一覧から即時に無効化できます。
+
補足:共有先を指定したリンクは、対象ユーザーのログインが必要です。対象未指定の場合は、リンク(必要ならパスワード)でアクセスできます。
+ +

容量上限

+

管理者は「システム設定 → 認証・PWA・SCIM → ファイル共有設定」で、1ファイル上限、全体容量、ユーザー容量、組織容量を設定できます。

diff --git a/views/help/index_en.php b/views/help/index_en.php index 3de9e92..247e457 100755 --- a/views/help/index_en.php +++ b/views/help/index_en.php @@ -16,6 +16,7 @@
  • Schedule and participation management
  • Workflow request and approval operations
  • Task board and comments
  • +
  • File sharing links with expiry, password, and target restrictions
  • Notifications and unread management
  • diff --git a/views/setting/security.php b/views/setting/security.php index 25fff98..5095353 100755 --- a/views/setting/security.php +++ b/views/setting/security.php @@ -168,6 +168,47 @@
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    +