notepad / server.js
willy880305's picture
Update server.js
0719ad1 verified
Raw History Blame Contribute Delete
24 kB
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 `
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} | 雲端筆記</title>
<style>
:root { --bg: #ffffff; --text: #333; --area-bg: #f9f9f9; --bar-bg: #e0e0e0; --border: #ccc; }
[data-theme="dark"] { --bg: #1e1e1e; --text: #e0e0e0; --area-bg: #2d2d2d; --bar-bg: #333; --border: #555; }
body { margin: 0; display: flex; flex-direction: column; height: 100vh; font-family: "Microsoft JhengHei", monospace; background: var(--bg); color: var(--text); transition: 0.3s; }
/* 頂部導航列 */
header { padding: 10px 20px; background: var(--bar-bg); display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.meta-info { display: flex; align-items: center; gap: 10px; flex: 1; }
.path-badge { background: #555; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 0.8em; }
.title-input { background: transparent; border: none; border-bottom: 1px dashed #999; font-size: 1.1em; color: var(--text); font-weight: bold; width: 200px; }
.title-input:focus { outline: none; border-bottom: 1px solid #007bff; }
.controls { display: flex; gap: 8px; }
button { padding: 6px 12px; cursor: pointer; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); font-size: 14px; }
button:hover { background: var(--bar-bg); }
button.primary { background: #007bff; color: white; border: none; }
button.danger { background: #dc3545; color: white; border: none; }
/* 編輯區域 */
.editor-container { flex: 1; position: relative; display: flex; }
textarea { flex: 1; width: 100%; border: none; padding: 20px; font-size: 16px; line-height: 1.6; background: var(--area-bg); color: var(--text); resize: none; outline: none; }
textarea.readonly-mode { color: #666; cursor: default; }
.markdown-view { flex: 1; padding: 20px; overflow-y: auto; background: var(--area-bg); line-height: 1.6; }
/* 彈窗與提示 */
.modal { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--bg); padding: 25px; border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); z-index: 100; min-width: 300px; }
.overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 90; }
.status-bar { font-size: 12px; opacity: 0.7; margin-left: 10px; }
#toast { position: fixed; bottom: 20px; right: 20px; background: #333; color: #fff; padding: 10px 20px; border-radius: 5px; opacity: 0; transition: opacity 0.5s; pointer-events: none; z-index: 200; }
label { display: block; margin-top: 10px; margin-bottom: 5px; font-weight: bold; }
input[type="password"], select { width: 100%; padding: 8px; margin-bottom: 10px; background: var(--area-bg); border: 1px solid var(--border); color: var(--text); box-sizing: border-box; }
</style>
</head>
<body>
<header>
<div class="meta-info">
<span class="path-badge">/${path}</span>
<input type="text" id="noteTitle" class="title-input" value="${title}" placeholder="在此輸入標題" ${canEdit ? '' : 'disabled'}>
<span class="status-bar">${permissionLabel} ${timeRemaining}</span>
<span id="saveStatus" class="status-bar"></span>
</div>
<div class="controls">
<button onclick="toggleTheme()">🌗</button>
<button onclick="toggleMarkdown()">${viewMode === 'editor' ? '👁️ 預覽' : '✏️ 編輯'}</button>
${canEdit ? `<button onclick="openSettings()">⚙️ 設定</button>` : ''}
${!canEdit && !isOwner ? `<button onclick="unlockEdit()" class="primary">🔓 解鎖編輯</button>` : ''}
${isAdmin ? '<a href="/admin"><button class="danger">管理後台</button></a>' : ''}
</div>
</header>
<div class="editor-container">
${viewMode === 'markdown'
? `<div class="markdown-view">${htmlContent}</div>`
: `<textarea id="content" placeholder="${canEdit ? '開始輸入內容...' : '唯讀模式:您沒有編輯權限。'}" spellcheck="false" ${readOnlyAttr}>${content}</textarea>`
}
</div>
<div class="overlay" id="overlay" onclick="closeSettings()"></div>
<div class="modal" id="settingsModal">
<h3>⚙️ 筆記設定</h3>
<p style="font-size:0.9em; color: #888;">只有擁有者可以更改這些設定。</p>
<label>🔒 訪問密碼 (留空則公開)</label>
<input type="password" id="setAccessPass" placeholder="設定後,看筆記需要密碼">
<label>✏️ 編輯密碼 (留空則僅擁有者可編輯)</label>
<input type="password" id="setEditPass" placeholder="分享此密碼給他人協作">
<label>⏱️ 自動銷毀 (閱後即焚)</label>
<select id="ttlSelect">
<option value="">永不銷毀</option>
<option value="10">10 分鐘後</option>
<option value="60">1 小時後</option>
<option value="1440">1 天後</option>
<option value="10080">7 天後</option>
</select>
<div style="margin-top: 20px; text-align: right;">
<button onclick="closeSettings()">取消</button>
<button onclick="saveSettings()" class="primary">儲存設定</button>
</div>
</div>
<div class="modal" id="unlockModal">
<h3>🔓 解鎖編輯權限</h3>
<p>請輸入此筆記的編輯密碼:</p>
<input type="password" id="inputEditPass" autofocus>
<div style="text-align: right;">
<button onclick="document.getElementById('unlockModal').style.display='none';document.getElementById('overlay').style.display='none';">取消</button>
<button onclick="submitUnlock()" class="primary">解鎖</button>
</div>
</div>
<div id="toast">已儲存</div>
<script>
const notePath = "${path}";
// 簡單的防抖動
let timeoutId;
// 初始化主題
if(localStorage.getItem('theme') === 'dark') document.body.setAttribute('data-theme', 'dark');
function toggleTheme() {
const isDark = document.body.getAttribute('data-theme') === 'dark';
document.body.setAttribute('data-theme', isDark ? 'light' : 'dark');
localStorage.setItem('theme', isDark ? 'light' : 'dark');
}
// 自動儲存邏輯 (標題或內容變動都存)
const textarea = document.getElementById('content');
const titleInput = document.getElementById('noteTitle');
function triggerSave() {
document.getElementById('saveStatus').innerText = '儲存中...';
clearTimeout(timeoutId);
timeoutId = setTimeout(saveContent, 800);
}
if(textarea && !textarea.readOnly) textarea.addEventListener('input', triggerSave);
if(titleInput && !titleInput.disabled) titleInput.addEventListener('input', triggerSave);
async function saveContent() {
const content = textarea ? textarea.value : '';
const title = titleInput ? titleInput.value : '';
try {
const res = await fetch('/' + notePath, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ content, title })
});
const data = await res.json();
if(data.success) {
document.getElementById('saveStatus').innerText = '已同步';
showToast('已儲存');
} else {
document.getElementById('saveStatus').innerText = '儲存失敗';
alert('儲存失敗:' + (data.error || '權限不足'));
location.reload();
}
} catch(e) { console.error(e); }
}
// 切換預覽
function toggleMarkdown() {
const url = new URL(window.location);
if("${viewMode}" === 'editor') url.searchParams.set('mode', 'markdown');
else url.searchParams.delete('mode');
window.location = url;
}
// 設定相關 UI
function openSettings() {
document.getElementById('settingsModal').style.display = 'block';
document.getElementById('overlay').style.display = 'block';
}
function closeSettings() {
document.querySelectorAll('.modal').forEach(m => m.style.display = 'none');
document.getElementById('overlay').style.display = 'none';
}
function unlockEdit() {
document.getElementById('unlockModal').style.display = 'block';
document.getElementById('overlay').style.display = 'block';
}
async function saveSettings() {
const accessPass = document.getElementById('setAccessPass').value;
const editPass = document.getElementById('setEditPass').value;
const ttl = document.getElementById('ttlSelect').value;
const payload = { settings: true };
if(accessPass !== "") payload.accessPassword = accessPass; // 空字串不傳送,避免意外清空,除非後端特別處理
if(accessPass === "") payload.clearAccessPass = true; // 特殊標記來清空
if(editPass !== "") payload.editPassword = editPass;
if(editPass === "") payload.clearEditPass = true;
if(ttl) payload.ttlMinutes = parseInt(ttl);
else payload.ttlMinutes = 0; // 0 代表取消
const res = await fetch('/' + notePath + '/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if(data.success) {
alert('設定已更新');
location.reload();
} else {
alert('錯誤:' + data.error);
}
}
async function submitUnlock() {
const password = document.getElementById('inputEditPass').value;
const res = await fetch('/' + notePath + '/auth-edit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ password })
});
const data = await res.json();
if(data.success) {
location.reload();
} else {
alert('密碼錯誤');
}
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.style.opacity = 1;
setTimeout(() => t.style.opacity = 0, 2000);
}
</script>
</body>
</html>
`;
};
// 密碼輸入頁面
const renderPasswordPage = (path, error = "") => {
return `
<html>
<head><title>受保護的筆記</title></head>
<body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:'Microsoft JhengHei', sans-serif;background:#f4f4f4;">
<form method="POST" action="/${path}/auth-access" style="text-align:center;background:white;padding:40px;border-radius:8px;box-shadow:0 4px 10px rgba(0,0,0,0.1)">
<h2>🔒 此筆記受密碼保護</h2>
<p>請輸入密碼以繼續訪問。</p>
<input type="password" name="password" placeholder="輸入訪問密碼" autofocus style="padding:10px;width:200px;margin-bottom:10px;border:1px solid #ccc;border-radius:4px;"><br>
<button type="submit" style="padding:10px 20px;cursor:pointer;background:#007bff;color:white;border:none;border-radius:4px;">解鎖</button>
${error ? `<p style="color:red;margin-top:10px;">${error}</p>` : ''}
</form>
</body>
</html>`;
};
// 管理員後台頁面
const renderAdminPage = (notes, error = "") => {
return `
<html>
<head><title>管理員後台</title>
<style>
body{font-family:'Microsoft JhengHei',monospace;padding:20px;max-width:1200px;margin:0 auto;}
table{width:100%;border-collapse:collapse;margin-top:20px;}
th,td{border:1px solid #ddd;padding:10px;text-align:left;}
th{background-color:#f8f9fa;}
tr:nth-child(even){background-color:#f9f9f9;}
.tag{font-size:12px;padding:2px 6px;border-radius:4px;color:white;}
.tag-lock{background:#d63384;} .tag-edit{background:#fd7e14;}
.btn{padding:5px 10px;cursor:pointer;border-radius:4px;border:none;color:white;}
.btn-del{background:#dc3545;} .btn-view{background:#007bff;text-decoration:none;padding:5px 10px;border-radius:4px;color:white;}
</style>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>🛠️ 系統管理後台</h1>
<a href="/" style="text-decoration:none;">回首頁</a>
</div>
${error ? `<p style="color:red">${error}</p>` : ''}
<table>
<tr><th>標題 (路徑)</th><th>字數</th><th>瀏覽數</th><th>最後更新</th><th>到期時間</th><th>安全狀態</th><th>操作</th></tr>
${notes.map(n => `
<tr>
<td><b>${n.title || '(無標題)'}</b><br><span style="color:#666;font-size:0.9em;">/${n.path}</span></td>
<td>${n.content.length}</td>
<td>${n.views}</td>
<td>${new Date(n.lastUpdated).toLocaleString()}</td>
<td>${n.expireAt ? new Date(n.expireAt).toLocaleString() : '永久'}</td>
<td>
${n.accessPassword ? '<span class="tag tag-lock">鎖</span>' : ''}
${n.editPassword ? '<span class="tag tag-edit">編</span>' : ''}
</td>
<td>
<a href="/${n.path}" target="_blank" class="btn-view">查看</a>
<form action="/admin/delete" method="POST" style="display:inline" onsubmit="return confirm('確定要刪除 /${n.path} 嗎?');">
<input type="hidden" name="path" value="${n.path}">
<button type="submit" class="btn btn-del">刪除</button>
</form>
</td>
</tr>`).join('')}
</table>
</body>
</html>`;
};
// 管理員登入頁
const renderAdminLogin = () => `
<html><body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:'Microsoft JhengHei';background:#f4f4f4;">
<form method="POST" action="/admin/login" style="text-align:center;padding:30px;background:white;border-radius:8px;">
<h2>管理員登入</h2>
<input type="password" name="password" placeholder="請輸入 Admin 密碼" style="padding:10px;"><br><br>
<button type="submit" style="padding:8px 20px;cursor:pointer;">登入</button>
</form>
</body></html>`;
// --- 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}`));