2026-05-15 12:04:54 +08:00
|
|
|
const express = require('express');
|
|
|
|
|
const path = require('node:path');
|
2026-05-15 23:02:33 +08:00
|
|
|
const multer = require('multer');
|
2026-05-15 12:04:54 +08:00
|
|
|
const {
|
|
|
|
|
createContractPdf,
|
|
|
|
|
listTemplates,
|
2026-05-15 23:02:33 +08:00
|
|
|
getTemplatePreviewPdf,
|
|
|
|
|
saveTemplate,
|
|
|
|
|
deleteTemplate,
|
2026-05-15 12:04:54 +08:00
|
|
|
} = require('./src/contractService');
|
|
|
|
|
|
|
|
|
|
const app = express();
|
2026-05-15 23:02:33 +08:00
|
|
|
const port = Number(process.env.PORT || 3001);
|
|
|
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
|
|
|
|
|
|
|
|
|
if (!process.env.ADMIN_PASSWORD) {
|
|
|
|
|
console.warn('[warn] ADMIN_PASSWORD 未設定,使用預設密碼 "admin"');
|
|
|
|
|
}
|
2026-05-15 12:04:54 +08:00
|
|
|
|
|
|
|
|
app.use(express.json({ limit: '1mb' }));
|
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
|
|
2026-05-15 23:02:33 +08:00
|
|
|
function adminAuth(req, res, next) {
|
|
|
|
|
const auth = req.headers.authorization || '';
|
|
|
|
|
if (!auth.startsWith('Basic ')) {
|
|
|
|
|
return res.status(401).json({ error: '需要管理員驗證。' });
|
|
|
|
|
}
|
|
|
|
|
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf-8');
|
|
|
|
|
const password = decoded.slice(decoded.indexOf(':') + 1);
|
|
|
|
|
if (password !== ADMIN_PASSWORD) {
|
|
|
|
|
return res.status(401).json({ error: '密碼錯誤。' });
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const upload = multer({
|
|
|
|
|
storage: multer.memoryStorage(),
|
|
|
|
|
fileFilter: (_req, file, cb) => {
|
|
|
|
|
const name = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
|
|
|
file.originalname = name;
|
|
|
|
|
const ext = path.extname(name).toLowerCase();
|
|
|
|
|
if (ext === '.doc' || ext === '.docx') return cb(null, true);
|
|
|
|
|
const err = new Error('僅接受 .doc / .docx 檔案。');
|
|
|
|
|
err.exposeToClient = true;
|
|
|
|
|
cb(err);
|
|
|
|
|
},
|
|
|
|
|
limits: { fileSize: 10 * 1024 * 1024 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Public routes ──────────────────────────────────────────
|
|
|
|
|
|
2026-05-15 12:04:54 +08:00
|
|
|
app.get('/api/health', (_req, res) => {
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/templates', async (_req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json({ templates: await listTemplates() });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 23:02:33 +08:00
|
|
|
app.get('/api/templates/:name/preview', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const pdfBuffer = await getTemplatePreviewPdf(req.params.name, req.query);
|
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
|
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
|
|
|
res.send(pdfBuffer);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
const msg = error.exposeToClient ? error.message : '預覽失敗';
|
|
|
|
|
res.status(error.exposeToClient ? 400 : 500)
|
|
|
|
|
.send(`<!DOCTYPE html><html><body style="padding:20px;color:#666;font-family:sans-serif">${msg}</body></html>`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 12:04:54 +08:00
|
|
|
app.post('/api/contracts/pdf', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await createContractPdf({
|
|
|
|
|
template: req.body.template,
|
|
|
|
|
monthlyRent: req.body.monthlyRent,
|
|
|
|
|
paymentDay: req.body.paymentDay,
|
|
|
|
|
deposit: req.body.deposit,
|
2026-05-15 23:02:33 +08:00
|
|
|
leaseStart: req.body.leaseStart,
|
|
|
|
|
leaseEnd: req.body.leaseEnd,
|
2026-05-15 12:04:54 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
|
|
|
res.setHeader(
|
|
|
|
|
'Content-Disposition',
|
|
|
|
|
`attachment; filename="rental-contract.pdf"; filename*=UTF-8''${encodeURIComponent(result.fileName)}`,
|
|
|
|
|
);
|
|
|
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
|
|
|
res.send(result.buffer);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
const status = error.exposeToClient ? 400 : 500;
|
|
|
|
|
res.status(status).json({
|
|
|
|
|
error: error.exposeToClient
|
|
|
|
|
? error.message
|
|
|
|
|
: 'PDF 產生失敗,請確認 LibreOffice 已安裝且範本格式正確。',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 23:02:33 +08:00
|
|
|
// ── Admin routes ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
app.get('/api/admin/check', adminAuth, (_req, res) => {
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/admin/templates', adminAuth, (req, res) => {
|
|
|
|
|
upload.single('template')(req, res, async (err) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
const status = err.exposeToClient || err instanceof multer.MulterError ? 400 : 500;
|
|
|
|
|
return res.status(status).json({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (!req.file) return res.status(400).json({ error: '請選擇檔案。' });
|
|
|
|
|
const name = await saveTemplate(req.file.originalname, req.file.buffer);
|
|
|
|
|
res.json({ ok: true, name });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
res.status(error.exposeToClient ? 400 : 500).json({
|
|
|
|
|
error: error.exposeToClient ? error.message : '上傳失敗。',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/api/admin/templates/:name', adminAuth, async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
await deleteTemplate(req.params.name);
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Error handler ──────────────────────────────────────────
|
|
|
|
|
|
2026-05-15 12:04:54 +08:00
|
|
|
app.use((error, _req, res, _next) => {
|
|
|
|
|
console.error(error);
|
2026-05-15 23:02:33 +08:00
|
|
|
const status = error.exposeToClient ? 400 : 500;
|
|
|
|
|
res.status(status).json({
|
|
|
|
|
error: error.exposeToClient ? error.message : '伺服器發生錯誤。',
|
|
|
|
|
});
|
2026-05-15 12:04:54 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.listen(port, () => {
|
|
|
|
|
console.log(`Rental contract PDF app is running at http://localhost:${port}`);
|
|
|
|
|
});
|