2025年12月30日 星期二

利用 laravel 12 做一個類似Google Drive 的作品-回收桶

零、目標:
1.專案目標:
   A.環境準備
   B.建立 Laravel 專案 + 登入系統
   C.建立資料庫(資料夾 / 檔案)
   D.資料夾 CRUD
   E.檔案上傳 / 下載
   F.檔案列表(像 Google Drive)
   G.分享連結
   H.權限與安全
    I.回收桶(Soft Delete)
2.目前目標:
    I.回收桶(Soft Delete)
       
a.軟刪除(Soft Delete)-使用者刪除檔案/資料夾時,資料仍保留,可恢復
       b.永久刪除-從回收桶刪除資料,才會真正從資料庫與 Storage 移除
       c.還原功能-可單一資料夾/檔案還原
       d.安全性-只允許資料擁有者操作回收桶
       e.統一介面-Folder / File 回收桶共用頁面,清楚呈現














2025年12月28日 星期日

利用 laravel 12 做一個類似Google Drive 的作品-權限與安全


零、目標:
1.專案目標:
   A.環境準備
   B.建立 Laravel 專案 + 登入系統
   C.建立資料庫(資料夾 / 檔案)
   D.資料夾 CRUD
   E.檔案上傳 / 下載
   F.檔案列表(像 Google Drive)
   G.分享連結
   H.權限與安全
    I.回收桶(Soft Delete)
2.目前目標:
   H.權限與安全
       a.用 Policy 做檔案與資料夾權限控管
       b.防止未授權存取(猜網址就能看別人檔案)
       c.Controller 符合 Laravel Best Practice

一、建立 FolderPolicy
    1.指令:php artisan make:policy FolderPolicy --model=Folder
    2.編輯 app/Policies/FolderPolicy.php
public function view(User $user, Folder $folder): bool
    {
        return $folder->user_id === $user->id;
    }
    
    public function update(User $user, Folder $folder): bool
    {
        return $folder->user_id === $user->id;
    }
    與
    public function delete(User $user, Folder $folder): bool
    {
        return $folder->user_id === $user->id;
    }
    如下圖

二、建立 FilePolicy
    1.指令:php artisan make:policy FilePolicy --model=File
    2.編輯 app/Policies/FilePolicy.php
    public function view(User $user, File $file): bool
    {
        return $file->user_id === $user->id;
    }

    public function download(User $user, File $file)
    {
        return $file->user_id === $user->id;
    }

    public function share(User $user, File $file)
    {
        return $file->user_id === $user->id;
    }

    如下圖

三、Controller 改為 authorize(重點)
    1.目的:Controller 變乾淨,安全集中管理
    2.編輯下列檔案
       a.FolderController@show
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
        
use AuthorizesRequests;
        
        // 安全檢查:只能看自己的資料夾
        $this->authorize('view',$folder);
        如下圖
       b.FileController@download
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
        
use AuthorizesRequests;
        
        $this->authorize('download',$file);
        如下圖
       c.ShareController@create
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
        
use AuthorizesRequests;
        
        $this->authorize('share',$file);
        如下圖
四、Blade 層級權限
    1.編輯 folders/show.blade.php
@can('share', $file)
    與
@endcan
    如下圖

五、測試
    1.用A使用者登入
    2.建立檔案
    3.複製檔案下載網址
    4.用B使用者登入
    5.貼上網址->出現403 Forbidden


利用 laravel 12 做一個類似Google Drive 的作品-分享連結


零、目標:
1.專案目標:
   A.環境準備
   B.建立 Laravel 專案 + 登入系統
   C.建立資料庫(資料夾 / 檔案)
   D.資料夾 CRUD
   E.檔案上傳 / 下載
   F.檔案列表(像 Google Drive)
   G.分享連結
   H.權限與安全
    I.回收桶(Soft Delete)
2.目前目標:
   G.分享連結(不用登入也能下載)
       a.產生公開分享連結
       b.未登入也可以下載檔案
       c.分享連結使用 token (安全)
       d.可設定到期時間(基本版)

一、建立 shares 資料夾
    1.指令:php artisan make:model Share -m
    2.編輯 database/migrations/xxxx_create_shares_table.php
$table->foreignId('file_id')->constrained()->cascadeOnDelete();
$table->string('token')->unique();
$table->timestamp('expires_at')->nullable();
    如下圖
    3.指令:php artisan migrate

二、Share Model
    1.編輯 app/Models/Share.php
    protected $fillable = [
        'file_id',
        'token',
        'expires_at',
    ];

    public function file(){
        return $this->belongsTo(File::class);
    }

    如下圖

三、修改 File Model (加關聯)
    1.編輯 app/Models/File.php
use App\Models\Share;
    
    public function share(){
    return $this->hasOne(Share::class);
    }
    如下圖

四、建立 ShareController
    1.指令:php artisan make:controller ShareController
    2.編輯 app/Http/Controllers/ShareController.php
use App\Models\File;
use App\Models\Share;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
    與
    // 建立分享連結
    public function create(File $file){
        abort_if($file->user_id !== auth()->id(), 403);

        $share = Share::firstOrCreate(
            ['file_id' => $file->id],
            ['token' => Str::uuid()]
        );

        return back()->with('share_link', route('share.download', $share->token));
    }

    // 公開下載
    public function download($token){
        $share = Share::where('token', $token)->firstOrFail();
        $file = $share->file;

        return Storage::disk('public')->download($file->path, $file->name);
    }

    如下圖

五、設定路由
    1.編輯 routes/web.php
    // Share
    Route::post('/files/{file}/share', [ShareController::class, 'create'])
        ->name('files.share');
    與
// Share
Route::get('/share/{token}', [ShareController::class, 'download'])
    ->name('share.download');
    如下圖

六、修改檔案列表畫面(加入分享按鈕)
    1.編輯 folders/show.blade.php
        {{-- 檔案列表 --}}
        <h2 class="font-semibold mb-2">檔案</h2>

        <ul class="space-y-2">
            @forelse($files as $file)
                <li class="border p-3 rounded flex justify-between items-center">
                    📄 {{ $file->name }}
                    <div class="space-x-3">
                        <a href="{{ route('files.download', $file) }}"
                            class="text-blue-600 hover:underline">
                        下載
                    </a>
                    <form method="POST" action="{{ route('files.share', $file) }}" class="inline">
                        @csrf
                        <button class="text-green-600 hover:underline">
                        分享
                        </button>
                    </form>
                    </div>
                </li>
                @empty
                <li class="text-gray-400">尚無檔案</li>
            @endforelse
        </ul>
        {{-- 顯示分享連結 --}}
        @if(session('share_link'))
            <div class="mt-4 p-3 bg-green-100 border rounded">
                分享連結:
            <a href="{{ session('share_link') }}"
                class="text-blue-600 underline"
                target="_blank">
                {{ session('share_link') }}
            </a>
            </div>
        @endif

    如下圖

七、測試流程
    1.點進資料夾
    2.上傳檔案
    3.點[分享]
    4.複製連結
    5.登出或開無痕視窗
    6.貼上連結->成功下載

八、延續利用 laravel 12 做一個類似Google Drive 的作品-檔案上傳下載的
十三、問題05,修正分享連結
    1.編輯 folders/index.blade.php

<x-app-layout>
    <div class="p-6 max-w-4xl mx-auto">

        {{-- Breadcrumb --}}
        <div class="mb-4 text-sm text-gray-500">
            我的雲端硬碟
        </div>

        <h1 class="text-xl font-bold mb-4">我的資料夾</h1>

        {{-- 成功訊息 --}}
        @if(session('success'))
            <div class="mb-4 text-green-600">
                {{ session('success') }}
            </div>
        @endif

        {{-- 新增根資料夾 --}}
        <form method="POST" action="{{ route('folders.store') }}" class="mb-4 flex gap-2">
            @csrf
            <input type="text" name="name" placeholder="新資料夾名稱" class="border rounded p-2 flex-1" required>
            <button class="bg-blue-500 text-white px-4 py-2 rounded">新增</button>
        </form>

        {{-- 根目錄資料夾列表 --}}
        <ul class="space-y-2 mb-6">
            @forelse($folders as $folder)
                <li class="border p-3 rounded hover:bg-gray-100 flex justify-between items-center"
                    x-data="{ editing: false, name: '{{ e($folder->name) }}' }">

                    {{-- 資料夾名稱 --}}
                    <div class="flex items-center gap-2">
                        📁
                        <template x-if="!editing">
                            <span @dblclick="editing = true" class="font-semibold text-blue-600 cursor-pointer">
                                {{ $folder->name }}
                            </span>
                        </template>
                        <template x-if="editing">
                            <form method="POST" action="{{ route('folders.update', $folder) }}" class="flex items-center gap-1">
                                @csrf
                                @method('PATCH')
                                <input type="text" name="name" x-model="name"
                                       @keydown.enter.prevent="$el.form.submit()"
                                       @keydown.escape="editing = false"
                                       class="border rounded p-1 text-sm w-32" autofocus required>
                                <button class="text-green-600 text-sm">儲存</button>
                                <button type="button" @click="editing = false" class="text-gray-500 text-sm">取消</button>
                            </form>
                        </template>
                    </div>

                    {{-- 操作按鈕 --}}
                    <div class="flex gap-2 items-center">
                        <a href="{{ route('folders.show', $folder) }}" class="text-sm text-blue-500">開啟</a>
                        <button type="button" @click="editing = true" class="text-sm text-gray-600">重新命名</button>
                        <form method="POST" action="{{ route('folders.destroy', $folder) }}"
                              onsubmit="return confirm('確定要刪除這個資料夾及其所有子資料夾嗎?')">
                            @csrf
                            @method('DELETE')
                            <button class="text-sm text-red-600">刪除</button>
                        </form>
                    </div>
                </li>
            @empty
                <li class="text-gray-400">尚無資料夾</li>
            @endforelse
        </ul>

        {{-- 多檔案上傳 --}}
        <form method="POST" action="{{ route('files.store') }}" enctype="multipart/form-data" class="mb-4 flex gap-2">
            @csrf
            <input type="hidden" name="folder_id" value="">
            <input type="file" name="files[]" multiple required>
            <button class="bg-green-600 text-white px-4 py-2 rounded">上傳檔案</button>
        </form>

        {{-- 檔案排序 --}}
        <div class="mb-2 flex items-center gap-2 text-sm text-gray-500">
            排序:
            @php
                $options = ['name' => '名稱', 'size' => '大小', 'created_at' => '上傳日期'];
            @endphp
            @foreach($options as $key => $label)
                <a href="{{ route('folders.index') }}?sort={{ $key }}&direction={{ $sort === $key && $direction === 'asc' ? 'desc' : 'asc' }}"
                   class="hover:underline {{ $sort === $key ? 'font-semibold' : '' }}">
                   {{ $label }}
                   @if($sort === $key)
                       {{ $direction === 'asc' ? '↑' : '↓' }}
                   @endif
                </a>
                @if(!$loop->last) | @endif
            @endforeach
        </div>

        {{-- 根目錄檔案列表 --}}
        <h2 class="font-semibold mb-2">檔案</h2>
        <ul class="space-y-2">
            @forelse($files as $file)
                <li class="border p-3 rounded flex justify-between items-center"
                    x-data="{ editing: false, name: '{{ e($file->name) }}' }">

                    {{-- 檔案名稱 + 大小 + 上傳日期 --}}
                    <div class="flex items-center gap-4">
                        📄
                        <template x-if="!editing">
                            <span @dblclick="editing = true" class="cursor-pointer text-blue-600">
                                {{ $file->name }}
                            </span>
                        </template>
                        <template x-if="editing">
                            <form method="POST" action="{{ route('files.update', $file) }}" class="flex items-center gap-1">
                                @csrf
                                @method('PATCH')
                                <input type="text" name="name" x-model="name"
                                       @keydown.enter.prevent="$el.form.submit()"
                                       @keydown.escape="editing = false"
                                       class="border rounded p-1 text-sm w-32" autofocus required>
                                <button class="text-green-600 text-sm">儲存</button>
                                <button type="button" @click="editing = false" class="text-gray-500 text-sm">取消</button>
                            </form>
                        </template>

                        {{-- 檔案大小 --}}
                        <span class="text-gray-500 text-sm">
                            @php
                                if($file->size >= 1048576){
                                    $size = number_format($file->size / 1048576, 2) . ' MB';
                                } elseif($file->size >= 1024){
                                    $size = number_format($file->size / 1024, 2) . ' KB';
                                } else {
                                    $size = $file->size . ' B';
                                }
                            @endphp
                            {{ $size }}
                        </span>

                        {{-- 上傳日期 --}}
                        <span class="text-gray-400 text-sm">{{ $file->created_at->format('Y-m-d H:i') }}</span>
                    </div>

                    {{-- 操作按鈕 --}}
                    <div class="flex justify-center gap-2 items-center">
                        <a href="{{ route('files.download', $file) }}" class="text-blue-600 hover:underline">下載</a>
                        <button type="button" @click="editing = true" class="text-sm text-gray-600">重新命名</button>
                        <form method="POST" action="{{ route('files.destroy', $file) }}" onsubmit="return confirm('確定要刪除這個檔案嗎?')">
                            @csrf
                            @method('DELETE')
                            <button class="text-red-600 text-sm">刪除</button>
                        </form>
                    </div>

                        {{-- 分享按鈕靠右 --}}
                    <div class="flex justify-end gap-2 items-center mt-2">
                        <form method="POST" action="{{ route('files.share', $file) }}" class="inline">
                            @csrf
                            <button type="submit" class="text-blue-600 text-sm">分享</button>
                        </form>

                        {{-- 顯示分享連結 --}}
                        @if(session('share_link_' . $file->id))
                            <span class="text-gray-500 text-sm ml-2">分享連結:
                            <a href="{{ session('share_link_' . $file->id) }}" target="_blank" class="text-blue-600">{{ session('share_link_' . $file->id) }}</a>
                            </span>
                        @endif
                    </div>
                </li>
            @empty
                <li class="text-gray-400">尚無檔案</li>
            @endforelse
        </ul>

    </div>
</x-app-layout>


    2.編輯 folders/show.blade.php

<x-app-layout>
    <div class="p-6 max-w-4xl mx-auto">

        {{-- Breadcrumb --}}
        <div class="mb-4 text-sm text-gray-500">
            <a href="{{ route('folders.index') }}" class="hover:underline">我的雲端硬碟</a>
            @foreach ($breadcrumbs as $crumb)
                / <a href="{{ route('folders.show', $crumb) }}" class="hover:underline">{{ $crumb->name }}</a>
            @endforeach
        </div>

        <h1 class="text-xl font-bold mb-4">📁 {{ $folder->name }}</h1>

        {{-- 成功訊息 --}}
        @if(session('success'))
            <div class="mb-4 text-green-600">
                {{ session('success') }}
            </div>
        @endif

        {{-- 子資料夾列表 --}}
        @if($subFolders->isNotEmpty())
            <h2 class="font-semibold mb-2">子資料夾</h2>
            <ul class="space-y-2 mb-6">
                @foreach($subFolders as $subFolder)
                    <li class="border p-3 rounded hover:bg-gray-100 flex justify-between items-center">
                        <div class="flex items-center gap-2">
                            📁
                            <a href="{{ route('folders.show', $subFolder) }}" class="text-blue-600">
                                {{ $subFolder->name }}
                            </a>
                        </div>

                        {{-- 子資料夾操作按鈕 --}}
                        <div class="flex gap-2 items-center">
                            <a href="{{ route('folders.show', $subFolder) }}" class="text-sm text-blue-500">開啟</a>
                            <button type="button" class="text-sm text-gray-600">重新命名</button>
                            <form method="POST" action="{{ route('folders.destroy', $subFolder) }}"
                                  onsubmit="return confirm('確定要刪除這個資料夾及其所有子資料夾嗎?')">
                                @csrf
                                @method('DELETE')
                                <button class="text-sm text-red-600">刪除</button>
                            </form>
                        </div>
                    </li>
                @endforeach
            </ul>
        @else
            <p class="text-gray-400">此資料夾下沒有子資料夾。</p>
        @endif

        {{-- 檔案排序 --}}
        <div class="mb-2 flex items-center gap-2 text-sm text-gray-500">
            排序:
            @php
                $options = ['name' => '名稱', 'size' => '大小', 'created_at' => '上傳日期'];
            @endphp
            @foreach($options as $key => $label)
                <a href="{{ route('folders.show', $folder) }}?sort={{ $key }}&direction={{ $sort === $key && $direction === 'asc' ? 'desc' : 'asc' }}"
                   class="hover:underline {{ $sort === $key ? 'font-semibold' : '' }}">
                   {{ $label }}
                   @if($sort === $key)
                       {{ $direction === 'asc' ? '↑' : '↓' }}
                   @endif
                </a>
                @if(!$loop->last) | @endif
            @endforeach
        </div>

        {{-- 檔案列表 --}}
        <h2 class="font-semibold mb-2">檔案</h2>
        <ul class="space-y-2">
            @forelse($files as $file)
                <li class="border p-3 rounded flex justify-between items-center"
                    x-data="{ editing: false, name: '{{ e($file->name) }}' }">

                    {{-- 檔案名稱 + 大小 + 上傳日期 --}}
                    <div class="flex items-center gap-4">
                        📄
                        <template x-if="!editing">
                            <span @dblclick="editing = true" class="cursor-pointer text-blue-600">
                                {{ $file->name }}
                            </span>
                        </template>
                        <template x-if="editing">
                            <form method="POST" action="{{ route('files.update', $file) }}" class="flex items-center gap-1">
                                @csrf
                                @method('PATCH')
                                <input type="text" name="name" x-model="name"
                                       @keydown.enter.prevent="$el.form.submit()"
                                       @keydown.escape="editing = false"
                                       class="border rounded p-1 text-sm w-32" autofocus required>
                                <button class="text-green-600 text-sm">儲存</button>
                                <button type="button" @click="editing = false" class="text-gray-500 text-sm">取消</button>
                            </form>
                        </template>

                        {{-- 檔案大小 --}}
                        <span class="text-gray-500 text-sm">
                            @php
                                if($file->size >= 1048576){
                                    $size = number_format($file->size / 1048576, 2) . ' MB';
                                } elseif($file->size >= 1024){
                                    $size = number_format($file->size / 1024, 2) . ' KB';
                                } else {
                                    $size = $file->size . ' B';
                                }
                            @endphp
                            {{ $size }}
                        </span>

                        {{-- 上傳日期 --}}
                        <span class="text-gray-400 text-sm">{{ $file->created_at->format('Y-m-d H:i') }}</span>
                    </div>

                    {{-- 操作按鈕 --}}
                    <div class="flex gap-2 items-center">
                        <a href="{{ route('files.download', $file) }}" class="text-blue-600 hover:underline">下載</a>
                        <button type="button" @click="editing = true" class="text-sm text-gray-600">重新命名</button>
                        <form method="POST" action="{{ route('files.destroy', $file) }}"
                              onsubmit="return confirm('確定要刪除這個檔案嗎?')">
                            @csrf
                            @method('DELETE')
                            <button class="text-red-600 text-sm">刪除</button>
                        </form>
                    </div>

                    {{-- 分享連結 --}}
                    <div class="flex gap-2 items-center mt-2">
                        <form method="POST" action="{{ route('files.share', $file) }}" class="inline">
                            @csrf
                            <button type="submit" class="text-blue-600 text-sm">分享</button>
                        </form>

                        {{-- 顯示分享連結 --}}
                        @if(session('share_link_' . $file->id))
                            <span class="text-gray-500 text-sm ml-2">分享連結:
                                <a href="{{ session('share_link_' . $file->id) }}" target="_blank" class="text-blue-600">{{ session('share_link_' . $file->id) }}</a>
                            </span>
                        @endif
                    </div>

                </li>
            @empty
                <li class="text-gray-400">尚無檔案</li>
            @endforelse
        </ul>

    </div>
</x-app-layout>


    3.結果如下:
    與




如何在 Laravel 12 使用 Gmail SMTP 寄信

一、前置條件 1.準備 Gmail「應用程式密碼」 (1).Gmail 帳號需開啟 兩步驟驗證 (2).前往Google帳戶 →安全性 →應用程式密碼 =>這組密碼就是等下 Laravel 要用的 MAIL_PASSWORD (3)建立 laravel 12 專案     ...