const express = require('express'); const mongoose = require('mongoose'); const bodyParser = require('body-parser'); const cookieSession = require('cookie-session'); const { marked } = require('marked'); const sanitizeHtml = require('sanitize-html'); const crypto = require('crypto'); const app = express(); const port = 7860; // --- 1. 資料庫連線與 Schema --- mongoose.connect(process.env.MONGODB_URI) .then(() => console.log('MongoDB 連線成功')) .catch(err => console.error(err)); const NoteSchema = new mongoose.Schema({ path: { type: String, required: true, unique: true }, // 網址路徑 title: { type: String, default: "" }, // 自定義標題 content: { type: String, default: "" }, ownerToken: { type: String, default: null }, // 認主金鑰 (隱藏) accessPassword: { type: String, default: null }, // 訪問(閱讀)密碼 editPassword: { type: String, default: null }, // 編輯密碼 expireAt: { type: Date, default: null }, // 自動銷毀 views: { type: Number, default: 0 }, lastUpdated: { type: Date, default: Date.now } }); // 設定 TTL 自動刪除 NoteSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }); const Note = mongoose.model('Note', NoteSchema); // --- 2. Middleware 設定 --- app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); app.use(cookieSession({ name: 'session', keys: ['secret-key-A', 'secret-key-B'], maxAge: 365 * 24 * 60 * 60 * 1000 // Session 1年有效 })); // --- 3. 頁面渲染函式 (前端 UI) --- const renderPage = (note, viewMode = 'editor', isOwner = false, canEdit = false, isAdmin = false) => { const content = (note && note.content) ? note.content : ''; const title = (note && note.title) ? note.title : '未命名筆記'; const path = note ? note.path : 'new'; // 計算剩餘時間 let timeRemaining = ''; if (note && note.expireAt) { const diff = new Date(note.expireAt) - new Date(); const mins = Math.floor(diff / 60000); timeRemaining = `(⏳ ${mins} 分鐘後銷毀)`; } const htmlContent = viewMode === 'markdown' ? sanitizeHtml(marked.parse(content)) : content; // 狀態判斷 const readOnlyAttr = canEdit ? '' : 'readonly'; const readOnlyClass = canEdit ? '' : 'readonly-mode'; const permissionLabel = isOwner ? '👑 擁有者' : (canEdit ? '✏️ 可編輯' : '👀 純瀏覽'); return ` ${title} | 雲端筆記
/${path} ${permissionLabel} ${timeRemaining}
${canEdit ? `` : ''} ${!canEdit && !isOwner ? `` : ''} ${isAdmin ? '' : ''}
${viewMode === 'markdown' ? `
${htmlContent}
` : `` }
已儲存
`; }; // 密碼輸入頁面 const renderPasswordPage = (path, error = "") => { return ` 受保護的筆記

🔒 此筆記受密碼保護

請輸入密碼以繼續訪問。


${error ? `

${error}

` : ''}
`; }; // 管理員後台頁面 const renderAdminPage = (notes, error = "") => { return ` 管理員後台

🛠️ 系統管理後台

回首頁
${error ? `

${error}

` : ''} ${notes.map(n => ` `).join('')}
標題 (路徑)字數瀏覽數最後更新到期時間安全狀態操作
${n.title || '(無標題)'}
/${n.path}
${n.content.length} ${n.views} ${new Date(n.lastUpdated).toLocaleString()} ${n.expireAt ? new Date(n.expireAt).toLocaleString() : '永久'} ${n.accessPassword ? '鎖' : ''} ${n.editPassword ? '編' : ''} 查看
`; }; // 管理員登入頁 const renderAdminLogin = () => `

管理員登入



`; // --- 4. 路由邏輯 --- // Helper: 檢查是否為擁有者或有編輯權限 const checkPermission = (note, session) => { if (!note) return { isOwner: true, canEdit: true }; // 新筆記預設全是 if (note.ownerToken && session.ownerTokens && session.ownerTokens.includes(note.ownerToken)) { return { isOwner: true, canEdit: true }; } if (session.unlockedEdits && session.unlockedEdits.includes(note.path)) { return { isOwner: false, canEdit: true }; } // 如果沒有設定 Owner (舊資料) 且沒有編輯密碼,視為公開編輯 (Backward Compatibility) // 但根據新邏輯,我們希望儘量鎖定。這裡設為:若無 ownerToken 且無 editPassword,暫時開放編輯,一旦編輯就認主。 if (!note.ownerToken && !note.editPassword) { return { isOwner: true, canEdit: true }; // 暫時給權限,下次存檔會補上 ownerToken } return { isOwner: false, canEdit: false }; }; // Admin 路由 app.get('/admin', async (req, res) => { if (req.session.isAdmin) { const notes = await Note.find().sort({ lastUpdated: -1 }); res.send(renderAdminPage(notes)); } else { res.send(renderAdminLogin()); } }); app.post('/admin/login', (req, res) => { if (req.body.password === process.env.ADMIN_PASSWORD) { req.session.isAdmin = true; res.redirect('/admin'); } else { res.send('密碼錯誤'); } }); app.post('/admin/delete', async (req, res) => { if (!req.session.isAdmin) return res.status(403).send('Forbidden'); await Note.deleteOne({ path: req.body.path }); res.redirect('/admin'); }); // 筆記路由 app.get('/:path', async (req, res) => { const path = req.params.path; if(path === 'favicon.ico') return res.status(404).end(); let note = await Note.findOne({ path }); // 1. 訪問權限檢查 (Access Password) if (note && note.accessPassword) { if (!req.session.unlockedAccess || !req.session.unlockedAccess.includes(path)) { // 如果是擁有者,直接跳過訪問密碼檢查 const perms = checkPermission(note, req.session); if (!perms.isOwner) { return res.send(renderPasswordPage(path)); } } } // 2. 編輯/擁有權限檢查 const { isOwner, canEdit } = checkPermission(note, req.session); if(note) { note.views += 1; await note.save(); } const viewMode = req.query.mode === 'markdown' ? 'markdown' : 'editor'; res.send(renderPage(note ? note : { path }, viewMode, isOwner, canEdit, req.session.isAdmin)); }); // 解鎖訪問 (閱讀) app.post('/:path/auth-access', async (req, res) => { const path = req.params.path; const { password } = req.body; const note = await Note.findOne({ path }); if (note && note.accessPassword === password) { req.session.unlockedAccess = req.session.unlockedAccess || []; req.session.unlockedAccess.push(path); res.redirect('/' + path); } else { res.send(renderPasswordPage(path, "密碼錯誤")); } }); // 解鎖編輯 (協作者) app.post('/:path/auth-edit', async (req, res) => { const path = req.params.path; const { password } = req.body; const note = await Note.findOne({ path }); if (note && note.editPassword === password) { req.session.unlockedEdits = req.session.unlockedEdits || []; req.session.unlockedEdits.push(path); res.json({ success: true }); } else { res.json({ success: false }); } }); // 儲存內容 (POST /:path) app.post('/:path', async (req, res) => { const path = req.params.path; const { content, title } = req.body; let note = await Note.findOne({ path }); // 檢查權限 const { isOwner, canEdit } = checkPermission(note, req.session); if (note && !canEdit) { return res.status(403).json({ success: false, error: "您只有閱讀權限" }); } if (!note) { // 建立新筆記 note = new Note({ path }); // 生成並分配 Owner Token const newToken = crypto.randomUUID(); note.ownerToken = newToken; req.session.ownerTokens = req.session.ownerTokens || []; req.session.ownerTokens.push(newToken); } else if (!note.ownerToken) { // 如果是舊筆記沒有主人,第一個編輯的人變成主人 const newToken = crypto.randomUUID(); note.ownerToken = newToken; req.session.ownerTokens = req.session.ownerTokens || []; req.session.ownerTokens.push(newToken); } note.content = content; if(title !== undefined) note.title = title; note.lastUpdated = Date.now(); await note.save(); res.json({ success: true }); }); // 儲存設定 app.post('/:path/settings', async (req, res) => { const path = req.params.path; const { accessPassword, clearAccessPass, editPassword, clearEditPass, ttlMinutes } = req.body; let note = await Note.findOne({ path }); // 只有擁有者 (Owner) 才能改設定! const { isOwner } = checkPermission(note, req.session); if (!isOwner) { return res.status(403).json({ success: false, error: "只有筆記擁有者可以更改設定" }); } if (!note) return res.status(404).json({error: "Note not found"}); if (clearAccessPass) note.accessPassword = null; else if (accessPassword) note.accessPassword = accessPassword; if (clearEditPass) note.editPassword = null; else if (editPassword) note.editPassword = editPassword; if (ttlMinutes > 0) { note.expireAt = new Date(Date.now() + ttlMinutes * 60000); } else if (ttlMinutes === 0) { note.expireAt = null; } await note.save(); res.json({ success: true }); }); app.get('/', (req, res) => { const randomStr = Math.random().toString(36).substring(7); res.redirect(`/${randomStr}`); }); app.listen(port, () => console.log(`Server running on port ${port}`));