iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0

前情提要

終於放假可以喘口氣了,持續挖坑與填坑!

設計 Delete 機制

  • 新增一個 Lambda Function
import os, json, boto3, jwt

s3 = boto3.client("s3")
BUCKET_NAME = os.environ.get("BUCKET_NAME", "exsky-backup-media")
SECRET = os.environ.get("JWT_SECRET", "mysecret")

def lambda_handler(event, context):
    method = event.get("requestContext", {}).get("http", {}).get("method")

    # --- CORS 預檢 ---
    if method == "OPTIONS":
        return {
            "statusCode": 200,
            "headers": {
                "Access-Control-Allow-Origin": "https://vlog.nipapa.tw",
                "Access-Control-Allow-Methods": "DELETE,OPTIONS",
                "Access-Control-Allow-Headers": "Authorization,Content-Type",
            },
            "body": ""
        }

    if method != "DELETE":
        return {"statusCode": 405, "body": "Method not allowed"}

    # --- JWT 驗證 ---
    headers = event.get("headers", {}) or {}
    auth = headers.get("authorization") or headers.get("Authorization") or ""
    if not auth.startswith("Bearer "):
        return _unauthorized("Missing token")

    token = auth.split(" ")[1]
    try:
        decoded = jwt.decode(token, SECRET, algorithms=["HS256"])
        username = decoded.get("username", "unknown")
    except Exception:
        return _unauthorized("Invalid token")

    # --- 解析 body 取得要刪的檔案 key ---
    try:
        body = json.loads(event.get("body", "{}"))
        key = body.get("key")
        if not key or not key.startswith(f"{username}/"):
            return _bad_request("Invalid key")
    except Exception:
        return _bad_request("Invalid body")

    # --- 刪除影片 & cover ---
    try:
        # 刪影片
        s3.delete_object(Bucket=BUCKET_NAME, Key=key)

        # 推導封面名稱
        base = os.path.basename(key)
        cover_key = f"{username}/covers/{os.path.splitext(base)[0]}.jpg"

        # 嘗試刪封面(若不存在會 404,不會影響主要刪除)
        try:
            s3.delete_object(Bucket=BUCKET_NAME, Key=cover_key)
        except Exception:
            pass

        return {
            "statusCode": 200,
            "headers": {"Access-Control-Allow-Origin": "https://vlog.nipapa.tw"},
            "body": json.dumps({
                "message": f"Deleted {key} and cover {cover_key}"
            }, ensure_ascii=False)
        }
    except Exception as e:
        return _server_error(str(e))


# ---- helpers ----
def _unauthorized(msg):
    return {
        "statusCode": 401,
        "headers": {"Access-Control-Allow-Origin": "https://vlog.nipapa.tw"},
        "body": json.dumps({"error": msg})
    }

def _bad_request(msg):
    return {
        "statusCode": 400,
        "headers": {"Access-Control-Allow-Origin": "https://vlog.nipapa.tw"},
        "body": json.dumps({"error": msg})
    }

def _server_error(msg):
    return {
        "statusCode": 500,
        "headers": {"Access-Control-Allow-Origin": "https://vlog.nipapa.tw"},
        "body": json.dumps({"error": msg})
    }

  • 配置 Role 給他 S3 DeleteObject 權限
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:ap-northeast-1:<AWS_ID>:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:<AWS_ID>:log-group:/aws/lambda/delete-vlog-video:*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": "s3:DeleteObject",
            "Resource": "arn:aws:s3:::exsky-backup-media/*"
        }
    ]
}
  • 配置 API Gateway Route 後,要允許 CORS 使用 Delete
  • 修改前端 / 前端使用的 javascript
const API_BASE = "https://iwlw3i3ys4.execute-api.ap-northeast-1.amazonaws.com/prod";

const form = document.getElementById("uploadForm");
const fileInput = document.getElementById("fileInput");
const statusDiv = document.getElementById("status");
const progressBar = document.getElementById("progressBar");
const logoutBtn = document.getElementById("logoutBtn");
const welcomeUser = document.getElementById("welcomeUser");

// 🚀 頁面載入時,先驗證 JWT
document.addEventListener("DOMContentLoaded", async () => {
  const token = await validateToken();
  if (!token) return;

  console.log("✅ JWT 驗證成功,顯示上傳功能");

  // 顯示 UI
  document.getElementById("uploadSection").style.display = "block";
  logoutBtn.style.display = "inline-block";

  // 綁定登出事件
  logoutBtn.addEventListener("click", () => {
    localStorage.removeItem("jwt");
    alert("已登出!");
    window.location.href = "login.html";
  });

  // 取得使用者名稱並顯示
  try {
    const res = await fetch(`${API_BASE}/validate`, {
      method: "GET",
      headers: { "Authorization": "Bearer " + token }
    });
    const data = await res.json();
    if (data.username) {
      welcomeUser.textContent = `👋 Hi, ${data.username}`;
    }
  } catch (err) {
    console.error("❌ 無法取得使用者名稱:", err);
  }
});

// ✅ 驗證 JWT 是否有效
async function validateToken() {
  const token = localStorage.getItem("jwt");
  if (!token) {
    window.location.href = "login.html";
    return null;
  }

  try {
    const res = await fetch(`${API_BASE}/validate`, {
      method: "GET",
      headers: { "Authorization": "Bearer " + token }
    });

    if (res.status === 401) {
      localStorage.removeItem("jwt");
      window.location.href = "login.html";
      return null;
    }
    return token;
  } catch (err) {
    console.error("❌ 驗證 API 錯誤:", err);
    return null;
  }
}

// ✅ 上傳影片
form.addEventListener("submit", async (e) => {
  e.preventDefault();

  const file = fileInput.files[0];
  if (!file) {
    statusDiv.textContent = "⚠️ 請先選擇影片!";
    return;
  }

  const token = await validateToken();
  if (!token) return;

  statusDiv.textContent = "正在請求上傳網址...";
  progressBar.value = 0;

  try {
    const res = await fetch(`${API_BASE}/generate-url`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer " + token
      },
      body: JSON.stringify({
        filename: file.name,
        contentType: file.type    // ✅ 傳給 Lambda
      })
    });

    const data = await res.json();
    if (!res.ok || !data.url) {
      statusDiv.textContent = "❌ 無法取得上傳 URL";
      return;
    }

    const uploadUrl = data.url;
    statusDiv.textContent = "正在上傳影片...";

    const xhr = new XMLHttpRequest();
    xhr.open("PUT", uploadUrl, true);
    xhr.setRequestHeader("Content-Type", file.type);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * 100;
        progressBar.value = percent;
      }
    };

    xhr.onload = () => {
      if (xhr.status === 200) {
        statusDiv.textContent = "✅ 上傳完成!";
      } else {
        statusDiv.textContent = `❌ 上傳失敗:${xhr.statusText}`;
      }
    };

    xhr.onerror = () => {
      statusDiv.textContent = "❌ 上傳錯誤,請稍後再試";
    };

    xhr.send(file);

  } catch (err) {
    console.error("❌ 上傳流程錯誤:", err);
    statusDiv.textContent = "❌ 上傳失敗";
  }
});

// ✅ 載入並顯示影片清單
async function loadVideos() {
  const token = localStorage.getItem("jwt");
  if (!token) {
    alert("請先登入!");
    window.location.href = "login.html";
    return;
  }

  try {
    const res = await fetch(`${API_BASE}/list-videos`, {
      method: "GET",
      headers: { "Authorization": "Bearer " + token }
    });

    const data = await res.json();

    if (!res.ok) {
      console.error("❌ list-videos 錯誤:", data);
      return;
    }

    const videoListDiv = document.getElementById("videoList");
    videoListDiv.innerHTML = ""; // 清空舊內容
    data.forEach(item => {
      // 外層卡片
      const card = document.createElement("div");
      card.className = "video-card";

      // 縮圖
      if (item.cover_url) {
        const img = document.createElement("img");
        img.src = item.cover_url;
        img.alt = item.decodedName;
        card.appendChild(img);
      } else {
        const placeholder = document.createElement("div");
        placeholder.className = "placeholder";
        placeholder.textContent = "🎬 無縮圖";
        card.appendChild(placeholder);
      }

      // 檔名連結
      const link = document.createElement("a");
      link.href = item.video_url;
      link.target = "_blank";
      link.textContent = item.decodedName;
      card.appendChild(link);

      // 檔案大小
      const sizeInfo = document.createElement("div");
      sizeInfo.className = "size";
      sizeInfo.textContent = `${(item.size / 1024 / 1024).toFixed(2)} MB`;
      card.appendChild(sizeInfo);

      // ❌ 刪除按鈕
      const delBtn = document.createElement("button");
      delBtn.textContent = "刪除";
      delBtn.style.marginTop = "8px";
      delBtn.onclick = async () => {
        if (!confirm(`確定刪除 ${item.decodedName}?`)) return;

        const token = localStorage.getItem("jwt");
        try {
          const res = await fetch(`${API_BASE}/delete-video`, {
            method: "DELETE",
            headers: {
              "Content-Type": "application/json",
              "Authorization": "Bearer " + token
            },
            body: JSON.stringify({ key: item.key })
          });
          const result = await res.json();

          if (res.ok) {
            alert("✅ 已刪除");
            card.remove(); // 直接移除卡片
          } else {
            alert("❌ 刪除失敗:" + (result.error || "未知錯誤"));
          }
        } catch (err) {
          console.error("刪除失敗:", err);
        }
      };
      card.appendChild(delBtn);

      videoListDiv.appendChild(card);
    });
  } catch (err) {
    console.error("❌ 載入影片失敗:", err);
  }
}

已完成 API

  1. ANY /generate-url

    一旦確立身份,用來產生上傳影片到 S3 的臨時網址

  2. POST /login

    使用者在登入頁面打完帳號密碼,透過此 API 送給後端程式進行 登入程序

  3. GET /login

    將請求轉導到 網站的 login.html 頁面

  4. ANY /register

    使用者在登入頁面打完帳號密碼,透過此 API 送給後端程式進行 註冊程序

  5. GET /validate

    用來檢查使用者瀏覽器中的 jwt 是否有效,藉此 驗證登入狀態

  6. ANY /list-videos

    用來檢視之前上傳過的影片、列出清單用來呈現在頁面上

  7. ANY /delete-video

    刪除指定影片 / 也順邊刪除 cover

  8. generate-vlog-cover (Amazon Lambda Function)

    當檔案新增到 S3 Bucket 後自動觸發,用 ffmpeg 去產出截圖。

待開發

  1. 指定影片,進行 逐字稿(字幕)產生/generate-subtitle
  2. 指定影片,進行 逐字稿翻譯/translate-url

結論

  • 發現 MOV 不一定有支援各種瀏覽器喔,所以之後要來統一轉檔!!

上一篇
【Day 17】 利用 S3 Event Trigger 捕捉事件,自動觸發 Amazon Lambda 產生縮圖
系列文
無法成為片師也想拍 Vlog?!個人影音小工具的誕生!18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言