Files
sendsecret/app/server.js
Lucas Orth 681fc1649a SendSecret: Erstimport und Umstellung auf Tag-Deployment
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>
2026-08-25 07:26:15 +02:00

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'))