export default { async fetch(request, env, ctx) { const { searchParams } = new URL(request.url); const token = searchParams.get("token"); const key = searchParams.get("key"); const action = searchParams.get("action"); const isDownload = searchParams.get("download"); const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; if (request.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); // --- 身分驗證邏輯 --- let userId = null; if (token) { userId = await verifyLineToken(token); if (!userId) return new Response("身分驗證失敗", { status: 401, headers: corsHeaders }); } // --- 安全刪除邏輯 --- if (request.method === "DELETE" || (request.method === "GET" && action === "delete")) { if (!userId || !key || !key.startsWith(`${userId}/`)) return new Response("非法操作", { status: 403, headers: corsHeaders }); try { const target = await env.DB.prepare("SELECT id FROM files WHERE userId = ? AND fileKey = ? LIMIT 1").bind(userId, key).first(); if (target) { await env.DB.prepare("DELETE FROM files WHERE id = ?").bind(target.id).run(); const others = await env.DB.prepare("SELECT id FROM files WHERE userId = ? AND fileKey = ? LIMIT 1").bind(userId, key).first(); if (!others) { await env.MY_BUCKET.delete(key); } } } catch (e) { console.error("Delete logic error", e); } return new Response(JSON.stringify({ success: true }), { headers: corsHeaders }); } if (request.method === "GET") { if (userId && key) { if (!key.startsWith(`${userId}/`)) return new Response("非法存取", { status: 403 }); const object = await env.MY_BUCKET.get(key); if (!object) return new Response("找不到檔案", { status: 404 }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("Access-Control-Allow-Origin", "*"); if (isDownload) { headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(key.split('/').pop())}"`); } else { headers.set("Content-Disposition", "inline"); } return new Response(object.body, { headers }); } if (userId) { const getHeaders = { "Content-Type": "application/json", ...corsHeaders }; try { const { results } = await env.DB.prepare("SELECT * FROM files WHERE userId = ? ORDER BY uploadedAt DESC").bind(userId).all(); const fileList = results.map(obj => ({ name: obj.fileName, key: obj.fileKey, size: obj.fileSize + " MB", date: new Date(obj.uploadedAt).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }) })); return new Response(JSON.stringify(fileList), { headers: getHeaders }); } catch (e) { const objects = await env.MY_BUCKET.list({ prefix: `${userId}/` }); const fileList = objects.objects.map(obj => ({ name: obj.key.split('/').pop(), key: obj.key, size: (obj.size / (1024 * 1024)).toFixed(2) + " MB", date: new Date(obj.uploaded).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }) })); return new Response(JSON.stringify(fileList.reverse()), { headers: getHeaders }); } } return new Response("Adan API is running.", { status: 200 }); } if (request.method === "POST") { try { const data = await request.json(); for (const event of data.events) { const replyToken = event.replyToken; const uId = event.source.userId; if (event.type === "message" && ['image', 'video', 'audio', 'file'].includes(event.message.type)) { // [改邏] 背景異步處理,不再阻塞主執行緒 ctx.waitUntil(handleR2Upload(event, env, uId)); } else if (event.type === "message" && event.message.type === "text") { const userText = event.message.text.trim(); let replyText = ""; if (userText === "開啟時光膠囊") { replyText = "🚀 【開啟時光膠囊】\n\n報告!回憶都好好地裝在膠囊裡囉!\n🔗 點擊進入查看:\nhttps://liff.line.me/2009633088-LqIEGiJZ"; } else if (userText === "阿丹幫幫我") { replyText = "❓ 【阿丹怎麼用?】\n\n📸 Q1:怎麼存東西?\n直接把想留的照片、影片等檔案「傳送」或「轉傳」給我就好囉!我會自動幫你按日期排整齊喔!\n💾 Q2:要去哪裡看存好的東西?\n點擊選單左邊「開啟我的時光膠囊」大按鈕即可。\n⏳ Q3:檔案會過期嗎?絕對不會!膠囊外殼比 LINE 紀錄還持久,只要阿丹還在,回憶就在!"; } else if (userText === "資料安全嗎") { replyText = "🛡️ 【資料安全嗎?】\n\n阿丹是個有原則的小幫手,你的回憶由我守護:\n✅只有你能存取:檔案鎖在專屬膠囊裡面。\n✅ 加密傳輸:採用安全的加密技術。\n✅ 絕對保密:阿丹只負責搬運,不會偷看你的秘密!\n\n✨ 【核心功能】\n💾 自動儲存:LINE 檔案一鍵轉傳,永久保存不失效。\n👀 隨時查閱:點擊選單左方的大按鈕即可直達你的時光膠囊。\n📦 每位用戶享有 500MB 免費儲存空間,單個檔案的傳送上限是250MB。"; } else { replyText = "收到!✨\n阿丹今天也是活力滿滿喔!如果有檔案想存,直接傳給我就可以囉!"; } await sendLineReply(replyToken, replyText, env); } } } catch (err) { console.error(err); } return new Response("OK", { status: 200 }); } } }; async function verifyLineToken(accessToken) { try { const response = await fetch('https://api.line.me/v2/profile', { headers: { 'Authorization': `Bearer ${accessToken}` } }); if (!response.ok) return null; const data = await response.json(); return data.userId; } catch (e) { return null; } } async function handleR2Upload(event, env, userId) { const messageId = event.message.id; const msgType = event.message.type; const MAX_FREE_SPACE_MB = 500; // [改邏] 移除寫死的單檔 250MB 上限檢查,改由 R2 實際串流結果判斷 const lineUrl = `https://api-data.line.me/v2/bot/message/${messageId}/content`; const response = await fetch(lineUrl, { headers: { 'Authorization': `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}` } }); if (response.ok) { const contentLength = response.headers.get("Content-Length"); const estimatedSizeMB = contentLength ? parseFloat((parseInt(contentLength) / (1024 * 1024)).toFixed(2)) : 0; const stats = await env.DB.prepare("SELECT SUM(fileSize) as total FROM files WHERE userId = ?").bind(userId).first(); const currentUsedMB = (stats.total || 0); if (currentUsedMB + estimatedSizeMB > MAX_FREE_SPACE_MB) { await sendLineReply(event.replyToken, `⚠️ 報告!時光膠囊已經裝不下了(${MAX_FREE_SPACE_MB}MB)!\n為了騰出空間裝新回憶,請先去刪除一些不再需要的檔案喔!`, env); return; } let originalName = event.message.fileName || ""; let ext = originalName.split('.').pop().toLowerCase(); if (!ext) { if (msgType === 'image') ext = 'jpg'; else if (msgType === 'video') ext = 'mp4'; else if (msgType === 'audio') ext = 'm4a'; else ext = 'bin'; } const blackList = ['exe', 'bat', 'sh', 'com', 'cmd', 'scr', 'vbs']; if (blackList.includes(ext)) { await sendLineReply(event.replyToken, "⚠️ 唔...阿丹為了保護膠囊的安全,不支援儲存可執行檔喔。", env); return; } const now = new Date(new Date().getTime() + 8 * 3600 * 1000); const randomSuffix = Math.random().toString(36).substring(2, 7); const displayFileName = originalName || `${now.getTime()}_${randomSuffix}.${ext}`; const folderType = ['image', 'video', 'audio'].includes(msgType) ? `${msgType}s` : 'files'; const filePath = `${userId}/${folderType}/${now.getFullYear()}/${now.getMonth()+1}/${displayFileName}`; await env.MY_BUCKET.put(filePath, response.body, { httpMetadata: { contentType: response.headers.get("Content-Type") || "application/octet-stream" } }); const obj = await env.MY_BUCKET.head(filePath); const actualSizeMB = parseFloat((obj.size / (1024 * 1024)).toFixed(2)); const finalName = originalName || `rec_${now.getTime()}.${ext}`; await env.DB.prepare(` INSERT INTO files (userId, fileKey, fileName, fileType, fileSize, fileHash) VALUES (?, ?, ?, ?, ?, ?) `).bind(userId, filePath, finalName, msgType, actualSizeMB, `streamed_${now.getTime()}`).run(); const totalNow = (currentUsedMB + actualSizeMB).toFixed(2); const replyText = `咻!阿丹已經收好了!這份回憶已經安全裝進時光膠囊啦!🎁\n📊 空間狀態:已用 ${totalNow}MB / 剩餘 ${(MAX_FREE_SPACE_MB - totalNow).toFixed(2)}MB`; await sendLineReply(event.replyToken, replyText, env); } } async function sendLineReply(token, text, env) { await fetch('https://api.line.me/v2/bot/message/reply', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}` }, body: JSON.stringify({ replyToken: token, messages: [{ type: 'text', text: text }] }) }); }