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} | 雲端筆記
${viewMode === 'markdown'
? `
${htmlContent}
`
: `
`
}
已儲存
`;
};
// 密碼輸入頁面
const renderPasswordPage = (path, error = "") => {
return `
受保護的筆記
`;
};
// 管理員後台頁面
const renderAdminPage = (notes, error = "") => {
return `
管理員後台
${error ? `${error}
` : ''}
| 標題 (路徑) | 字數 | 瀏覽數 | 最後更新 | 到期時間 | 安全狀態 | 操作 |
${notes.map(n => `
${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 ? '編' : ''}
|
查看
|
`).join('')}
`;
};
// 管理員登入頁
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}`));