Wrapper-Frontend vor cryptgeon: der Browser verschluesselt lokal, per Mail geht nur der Link raus. Fuer das Deployment nach den Konventionen aus DEPLOY.md hergerichtet: - docker-compose.yml mit festem Projekt- und Container-Namen, kein ports-Mapping, Healthcheck als Deploy-Gate. cryptgeon und redis liegen im internen Netz, nur app haengt im Web-Netz. - cryptgeon von latest auf 2.9.3 gepinnt. Das ist derselbe Stand, den latest bisher geliefert hat; 2.6.2 existiert nicht. - /healthz in server.js, vor dem Catch-all-Proxy registriert. - Dockerfile auf npm ci mit Lockfile und non-root umgestellt. - .gitea/workflows/deploy.yml: Build und Syntaxpruefung vor dem Deploy, .env aus dem Repo-Secret DOTENV. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
3.8 KiB
JavaScript
111 lines
3.8 KiB
JavaScript
'use strict'
|
|
|
|
const express = require('express')
|
|
const nodemailer = require('nodemailer')
|
|
const { createProxyMiddleware } = require('http-proxy-middleware')
|
|
|
|
const {
|
|
SMTP_HOST,
|
|
SMTP_PORT = '587',
|
|
SMTP_USER,
|
|
SMTP_PASS,
|
|
SMTP_FROM,
|
|
RECIPIENT_EMAIL,
|
|
APP_URL = '',
|
|
} = process.env
|
|
|
|
const app = express()
|
|
// express.json() is intentionally NOT applied globally — it would consume the
|
|
// request body stream before http-proxy-middleware can forward it to cryptgeon.
|
|
app.use(express.static('public', { index: false }))
|
|
|
|
app.get('/', (_req, res) => res.sendFile('index.html', { root: 'public' }))
|
|
app.get('/link', (_req, res) => res.sendFile('link.html', { root: 'public' }))
|
|
|
|
// Health-Endpoint fuer den Docker-Healthcheck. Muss vor dem Catch-all-Proxy
|
|
// stehen, sonst landet die Anfrage bei cryptgeon.
|
|
app.get('/healthz', (_req, res) => res.json({ ok: true }))
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: SMTP_HOST,
|
|
port: Number(SMTP_PORT),
|
|
secure: Number(SMTP_PORT) === 465,
|
|
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
|
})
|
|
|
|
app.post('/api/send', express.json(), async (req, res) => {
|
|
const { url, name } = req.body
|
|
|
|
if (!url || typeof url !== 'string' || !url.includes('/note/')) {
|
|
return res.status(400).json({ error: 'Ungültige Anfrage' })
|
|
}
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
return res.status(400).json({ error: 'Name fehlt' })
|
|
}
|
|
|
|
const senderName = name.trim()
|
|
|
|
// Reject URLs that don't originate from our own domain
|
|
if (APP_URL && !url.startsWith(APP_URL)) {
|
|
return res.status(400).json({ error: 'Ungültige URL' })
|
|
}
|
|
|
|
try {
|
|
await transporter.sendMail({
|
|
from: SMTP_FROM,
|
|
to: RECIPIENT_EMAIL,
|
|
subject: `Neues Passwort von ${senderName}`,
|
|
text: [
|
|
`${senderName} hat Ihnen sicher ein Passwort übermittelt.`,
|
|
'',
|
|
'Klicken Sie auf den folgenden Link um es einzusehen:',
|
|
url,
|
|
'',
|
|
'Dieser Link ist einmalig abrufbar und löscht sich nach dem ersten Öffnen automatisch.',
|
|
].join('\n'),
|
|
html: `
|
|
<div style="font-family:'Manrope',sans-serif;max-width:480px;margin:0 auto;padding:32px 24px;background:#fff;border:1px solid #E5E7EB;border-radius:4px;">
|
|
<p style="margin:0 0 4px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#1E40AF;">
|
|
Neues Passwort erhalten
|
|
</p>
|
|
<p style="margin:0 0 20px;font-size:16px;font-weight:600;color:#111827;">
|
|
${senderName}
|
|
</p>
|
|
<p style="margin:0 0 20px;font-size:14px;color:#6B7280;line-height:1.6;">
|
|
hat Ihnen sicher ein Passwort übermittelt.
|
|
</p>
|
|
<a href="${url}"
|
|
style="display:inline-block;background:#1E40AF;color:#fff;padding:12px 20px;border-radius:4px;text-decoration:none;font-weight:600;font-size:14px;margin:0 0 24px;">
|
|
Passwort abrufen →
|
|
</a>
|
|
<p style="margin:0;font-size:12px;color:#9CA3AF;line-height:1.5;border-top:1px solid #E5E7EB;padding-top:16px;">
|
|
Dieser Link ist <strong>einmalig abrufbar</strong> und löscht sich nach dem ersten Öffnen automatisch.
|
|
</p>
|
|
</div>
|
|
`,
|
|
})
|
|
res.json({ ok: true })
|
|
} catch (err) {
|
|
console.error('Mail error:', err.message)
|
|
res.status(500).json({ error: 'E-Mail konnte nicht gesendet werden' })
|
|
}
|
|
})
|
|
|
|
// All other requests → proxy to cryptgeon (API + note decrypt UI)
|
|
app.use(
|
|
createProxyMiddleware({
|
|
target: 'http://cryptgeon:8000',
|
|
changeOrigin: true,
|
|
on: {
|
|
error: (err, req, res) => {
|
|
console.error(`Proxy error [${req.method} ${req.url}]:`, err.message)
|
|
if (!res.headersSent) {
|
|
res.status(502).json({ error: 'Cryptgeon nicht erreichbar' })
|
|
}
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
app.listen(3000, () => console.log('SendSecret listening on :3000'))
|