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>
This commit is contained in:
Lucas Orth
2026-08-25 07:26:15 +02:00
commit 681fc1649a
19 changed files with 3096 additions and 0 deletions

6
app/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.git
*.log
.env
public/customer.js
public/link.js

22
app/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Build stage - alle Abhaengigkeiten, Frontend-Bundles bauen.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY build.js ./
COPY src ./src
COPY public ./public
RUN npm run build
# Production stage - nur Runtime-Abhaengigkeiten und fertige Assets.
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server.js ./
COPY --from=build /app/public ./public
USER node
# Dokumentiert den internen Port, oeffnet nichts.
EXPOSE 3000
CMD ["node", "server.js"]

33
app/build.js Normal file
View File

@@ -0,0 +1,33 @@
'use strict'
const { build } = require('esbuild')
const { mkdirSync } = require('fs')
mkdirSync('public', { recursive: true })
// occulto uses require('crypto') internally; shim it to the browser Web Crypto API
const cryptoBrowserShim = {
name: 'crypto-browser-shim',
setup(b) {
b.onResolve({ filter: /^crypto$/ }, () => ({
path: 'crypto',
namespace: 'crypto-browser-shim',
}))
b.onLoad({ filter: /.*/, namespace: 'crypto-browser-shim' }, () => ({
contents: 'module.exports = { webcrypto: globalThis.crypto }',
}))
},
}
build({
entryPoints: ['src/customer.js', 'src/link.js'],
bundle: true,
outdir: 'public',
format: 'iife',
target: ['es2020'],
minify: true,
platform: 'browser',
plugins: [cryptoBrowserShim],
})
.then(() => console.log('✓ Bundles built: public/customer.js, public/link.js'))
.catch((err) => { console.error(err); process.exit(1) })

1533
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
app/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "sendsecret",
"version": "1.0.0",
"description": "Custom frontend wrapper for cryptgeon with auto email",
"main": "server.js",
"scripts": {
"build": "node build.js",
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"http-proxy-middleware": "^2.0.7",
"nodemailer": "^6.9.0",
"occulto": "2.0.6"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}

171
app/public/index.html Normal file
View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort sicher übermitteln – Lucas Orth</title>
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="https://lucas-orth.de/wp-content/uploads/2023/06/favicon-scaled.webp">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="page">
<!-- ── Header ─────────────────────────────────── -->
<header class="header">
<nav class="nav container">
<a href="https://lucas-orth.de" target="_blank" rel="noopener">
<img
src="https://lucas-orth.de/wp-content/uploads/2023/06/Lucas-Orth-black-high-res_ergebnis-scaled-e1687249204543.webp"
alt="Lucas Orth"
class="logo"
>
</a>
<a href="https://lucas-orth.de/kontakt" target="_blank" rel="noopener" class="btn btn--primary">
Kontakt
</a>
</nav>
</header>
<main style="display:contents">
<!-- ── Hero ───────────────────────────────────── -->
<section class="hero">
<div class="container">
<div class="hero__inner">
<!-- Form State -->
<div id="state-form">
<p class="eyebrow">Sicher &amp; verschlüsselt</p>
<h1 class="hero-title">
Passwort sicher<br>
<span class="accent">übermitteln.</span>
</h1>
<p class="lead">
Geben Sie Ihr Passwort oder Ihre Zugangsdaten ein.
Der Link wird Ende-zu-Ende-verschlüsselt direkt an
Lucas Orth gesendet – Sie müssen nichts weiter tun.
</p>
<form id="form" class="hero-form" novalidate>
<div class="field">
<label for="sender-name" class="label">Ihr Name <span style="color:var(--color-error)">*</span></label>
<input
id="sender-name"
type="text"
class="input"
placeholder="z. B. Max Mustermann"
required
autocomplete="name"
spellcheck="false"
>
</div>
<div class="field">
<label for="text" class="label">Ihr Passwort / Zugangsdaten <span style="color:var(--color-error)">*</span></label>
<textarea
id="text"
class="textarea"
placeholder="z. B.&#10;Benutzername: max@beispiel.de&#10;Passwort: mein-passwort-123"
rows="6"
required
autocomplete="off"
autocorrect="off"
spellcheck="false"
></textarea>
</div>
<div id="state-error" hidden>
<div class="alert alert--error" role="alert">
<span id="error-msg"></span>
</div>
</div>
<button id="submit" type="submit" class="btn btn--primary">
<span id="submit-label">Jetzt sicher senden</span>
</button>
<p class="hint">Ende-zu-Ende verschlüsselt &nbsp;·&nbsp; Einmalig abrufbar &nbsp;·&nbsp; Kein Speichern</p>
</form>
</div>
<!-- Success State -->
<div id="state-success" hidden>
<div class="success-icon" aria-hidden="true">✓</div>
<p class="eyebrow">Erfolgreich übermittelt</p>
<h2 class="hero-title">
Passwort wurde<br>
<span class="accent">übermittelt.</span>
</h2>
<p class="lead">
Ihr Passwort wurde sicher verschlüsselt und direkt an Lucas Orth
weitergeleitet. Sie müssen nichts weiter tun.
</p>
</div>
</div>
</div>
</section>
<!-- ── Steps Section ──────────────────────────── -->
<section class="section-steps">
<div class="container">
<div class="section-header">
<p class="eyebrow">So funktioniert es</p>
<h2 class="section-title">In drei Schritten übermittelt.</h2>
<p class="lead">
Kein Account nötig, keine versteckten Schritte –
einfach eingeben und absenden.
</p>
</div>
<div class="steps-grid">
<div class="step-card">
<span class="step-num">01</span>
<h3 class="step-title">Text eingeben</h3>
<p class="step-text">
Tragen Sie Ihr Passwort oder Ihre Zugangsdaten in das Textfeld ein.
</p>
</div>
<div class="step-card">
<span class="step-num">02</span>
<h3 class="step-title">Sicher senden</h3>
<p class="step-text">
Mit einem Klick wird der Text verschlüsselt und der Link
automatisch an Lucas Orth gesendet.
</p>
</div>
<div class="step-card">
<span class="step-num">03</span>
<h3 class="step-title">Fertig</h3>
<p class="step-text">
Der Link ist einmalig abrufbar und löscht sich nach dem
ersten Öffnen automatisch.
</p>
</div>
</div>
</div>
</section>
</main>
<!-- ── Footer ─────────────────────────────────── -->
<footer class="footer">
<div class="container">
<div class="footer__inner">
<span>© 2026 Lucas Orth – Webdesign &amp; IT-Betreuung</span>
<div class="footer__links">
<a href="https://lucas-orth.de/datenschutz" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://lucas-orth.de/impressum" target="_blank" rel="noopener">Impressum</a>
</div>
</div>
</div>
</footer>
</div>
<script src="/customer.js"></script>
</body>
</html>

174
app/public/link.html Normal file
View File

@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link generieren – Lucas Orth</title>
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="https://lucas-orth.de/wp-content/uploads/2023/06/favicon-scaled.webp">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="page">
<!-- ── Header ─────────────────────────────────── -->
<header class="header">
<nav class="nav container">
<a href="https://lucas-orth.de" target="_blank" rel="noopener">
<img
src="https://lucas-orth.de/wp-content/uploads/2023/06/Lucas-Orth-black-high-res_ergebnis-scaled-e1687249204543.webp"
alt="Lucas Orth"
class="logo"
>
</a>
<a href="https://lucas-orth.de/kontakt" target="_blank" rel="noopener" class="btn btn--primary">
Kontakt
</a>
</nav>
</header>
<main style="display:contents">
<!-- ── Hero ───────────────────────────────────── -->
<section class="hero">
<div class="container">
<div class="hero__inner">
<!-- Form State -->
<div id="state-form">
<p class="eyebrow">Für interne Nutzung</p>
<h1 class="hero-title">
Sicheren Link<br>
<span class="accent">erstellen.</span>
</h1>
<p class="lead">
Text eingeben und einen einmalig abrufbaren, verschlüsselten
Link generieren. Der Link wird nicht automatisch versendet.
</p>
<form id="form" class="hero-form" novalidate>
<div class="field">
<label for="text" class="label">Geheimtext</label>
<textarea
id="text"
class="textarea"
placeholder="Geheimtext eingeben …"
rows="6"
required
autocomplete="off"
autocorrect="off"
spellcheck="false"
></textarea>
</div>
<div id="state-error" hidden>
<div class="alert alert--error" role="alert">
<span id="error-msg"></span>
</div>
</div>
<button id="submit" type="submit" class="btn btn--primary">
<span id="submit-label">Link erstellen</span>
</button>
</form>
</div>
<!-- Result State -->
<div id="state-result" hidden>
<p class="eyebrow">Link erstellt</p>
<h2 class="hero-title">
Bereit zum<br>
<span class="accent">Versenden.</span>
</h2>
<p class="lead">
Einmalig abrufbar – nach dem ersten Öffnen wird der Link
automatisch gelöscht.
</p>
<div class="hero-form">
<div class="field">
<label for="link-output" class="label">Ihr sicherer Link</label>
<div class="link-row">
<input
id="link-output"
type="text"
class="input input--mono"
readonly
aria-label="Generierter Link"
>
<button id="copy-btn" type="button" class="btn btn--secondary">
<span id="copy-label">Kopieren</span>
</button>
</div>
</div>
<button type="button" class="btn btn--ghost" onclick="location.reload()">
Neuen Link erstellen
</button>
</div>
</div>
</div>
</div>
</section>
<!-- ── Steps Section ──────────────────────────── -->
<section class="section-steps">
<div class="container">
<div class="section-header">
<p class="eyebrow">So funktioniert es</p>
<h2 class="section-title">Link erstellen &amp; teilen.</h2>
<p class="lead">
Ende-zu-Ende verschlüsselt – der Server sieht niemals den Klartext.
</p>
</div>
<div class="steps-grid">
<div class="step-card">
<span class="step-num">01</span>
<h3 class="step-title">Text eingeben</h3>
<p class="step-text">
Passwort oder Geheimtext ins Formular eingeben.
</p>
</div>
<div class="step-card">
<span class="step-num">02</span>
<h3 class="step-title">Link kopieren</h3>
<p class="step-text">
Den generierten Link mit dem Kopieren-Button in die
Zwischenablage übernehmen.
</p>
</div>
<div class="step-card">
<span class="step-num">03</span>
<h3 class="step-title">Einmalig abrufbar</h3>
<p class="step-text">
Nach dem ersten Öffnen löscht sich der Inhalt automatisch
und unwiderruflich.
</p>
</div>
</div>
</div>
</section>
</main>
<!-- ── Footer ─────────────────────────────────── -->
<footer class="footer">
<div class="container">
<div class="footer__inner">
<span>© 2026 Lucas Orth – Webdesign &amp; IT-Betreuung</span>
<div class="footer__links">
<a href="https://lucas-orth.de/datenschutz" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://lucas-orth.de/impressum" target="_blank" rel="noopener">Impressum</a>
</div>
</div>
</div>
</footer>
</div>
<script src="/link.js"></script>
</body>
</html>

400
app/public/style.css Normal file
View File

@@ -0,0 +1,400 @@
/* ── Reset ───────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens (1:1 domaincheck.lucas-orth.de) ───────── */
:root {
--color-accent: #1E40AF;
--color-accent-hover: #1E378A;
--color-ink: #111827;
--color-ink-muted: #6B7280;
--color-ink-light: #9CA3AF;
--color-surface: #FFFFFF;
--color-surface-offset: #FAFAFA; /* footer bg */
--color-surface-section: #F3F4F6; /* hero + steps section bg */
--color-border: #E5E7EB;
--color-border-strong: #D1D5DB; /* step numbers */
--color-success: #16A34A;
--color-success-bg: #F0FDF4;
--color-success-border: #BBF7D0;
--color-error: #DC2626;
--color-error-bg: #FEF2F2;
--color-error-border: #FECACA;
--font-heading: 'Space Grotesk', sans-serif;
--font-body: 'Manrope', sans-serif;
--radius: 4px;
--shadow-card: 0 1px 4px 0 rgba(0,0,0,0.07), 0 1px 2px -1px rgba(0,0,0,0.05);
--transition: 180ms;
}
/* ── Base ────────────────────────────────────────────────── */
html {
font-family: var(--font-body);
font-size: 16px;
color: var(--color-ink);
background: var(--color-surface-section);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a { text-decoration: none; color: inherit; }
/* ── Page ────────────────────────────────────────────────── */
.page {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* ── Container (max-w-5xl = 1024px, px-6 = 24px) ────────── */
.container {
max-width: 1024px;
margin: 0 auto;
padding: 0 24px;
}
/* ── Header ──────────────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 40;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
height: 64px;
flex-shrink: 0;
}
.nav {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Logo: h-[45px] wie im Domainchecker */
.logo {
height: 45px;
width: auto;
object-fit: contain;
display: block;
}
/* ── Hero Section ────────────────────────────────────────── */
/* bg-surface-section border-b border-border, py-20 md:py-28 */
.hero {
background: var(--color-surface-section);
border-bottom: 1px solid var(--color-border);
padding: 80px 0;
flex: 1;
}
@media (min-width: 768px) {
.hero { padding: 112px 0; }
}
.hero__inner { max-width: 672px; } /* max-w-xl ≈ max-w-2xl für form */
/* ── Eyebrow ─────────────────────────────────────────────── */
/* text-sm font-semibold tracking-widest uppercase text-accent mb-4 */
.eyebrow {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent);
margin-bottom: 16px;
}
/* ── Hero Title ──────────────────────────────────────────── */
/* text-4xl md:text-6xl leading-tight mb-4 */
.hero-title {
font-family: var(--font-heading);
font-size: 36px;
font-weight: 700;
color: var(--color-ink);
line-height: 1.15;
margin-bottom: 16px;
letter-spacing: -0.01em;
}
.hero-title .accent { color: var(--color-accent); }
@media (min-width: 768px) {
.hero-title { font-size: 60px; line-height: 1.1; }
}
/* ── Lead Text ───────────────────────────────────────────── */
/* text-lg mb-10 max-w-xl */
.lead {
font-size: 18px;
color: var(--color-ink-muted);
line-height: 1.7;
margin-bottom: 40px;
max-width: 512px;
}
/* ── Hero Form ───────────────────────────────────────────── */
/* max-w-2xl = 672px */
.hero-form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 672px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--color-ink);
}
/* Textarea: h-14 equivalent stacked, border-btn focus */
.textarea,
.input {
width: 100%;
padding: 12px 20px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 16px;
color: var(--color-ink);
background: var(--color-surface);
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
}
.textarea {
resize: vertical;
min-height: 130px;
line-height: 1.6;
}
.textarea::placeholder,
.input::placeholder {
color: var(--color-ink-light);
}
.textarea:focus,
.input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(30, 64, 175, 0.15);
}
.input--mono {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
color: var(--color-ink-muted);
background: #F9FAFB;
word-break: break-all;
}
/* ── Hint ────────────────────────────────────────────────── */
/* text-xs text-ink-light mt-4 */
.hint {
font-size: 12px;
color: var(--color-ink-light);
margin-top: 4px;
}
/* ── Buttons ─────────────────────────────────────────────── */
/* px-4 py-3 font-semibold text-base rounded-btn transition */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 16px;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
cursor: pointer;
border: none;
text-decoration: none;
transition: background-color var(--transition), opacity var(--transition);
white-space: nowrap;
line-height: 1;
-webkit-appearance: none;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--primary {
background: var(--color-accent);
color: #FAFAFA;
}
.btn--primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn--secondary {
background: var(--color-surface);
color: var(--color-ink);
border: 1px solid var(--color-border-strong);
}
.btn--secondary:hover:not(:disabled) { background: #F9FAFB; }
.btn--ghost {
background: transparent;
color: var(--color-ink-muted);
font-weight: 500;
}
.btn--ghost:hover:not(:disabled) {
background: var(--color-border);
color: var(--color-ink);
}
.btn--copied {
background: var(--color-success-bg) !important;
color: var(--color-success) !important;
border-color: var(--color-success-border) !important;
}
/* ── Alert ───────────────────────────────────────────────── */
.alert {
padding: 11px 14px;
border-radius: var(--radius);
font-size: 14px;
line-height: 1.5;
}
.alert--error {
background: var(--color-error-bg);
color: var(--color-error);
border: 1px solid var(--color-error-border);
}
/* ── Link Row ────────────────────────────────────────────── */
.link-row { display: flex; gap: 8px; align-items: stretch; }
.link-row .input { flex: 1; min-width: 0; }
/* ── Success State ───────────────────────────────────────── */
.success-icon {
width: 52px;
height: 52px;
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
}
/* ── Steps Section ───────────────────────────────────────── */
/* bg-surface-section py-20 – gleicher Hintergrund wie Hero, weiße Karten */
.section-steps {
background: var(--color-surface-section);
padding: 80px 0;
}
.section-steps .lead { margin-bottom: 0; }
/* Section heading: text-3xl md:text-4xl */
.section-title {
font-family: var(--font-heading);
font-size: 30px;
font-weight: 700;
color: var(--color-ink);
line-height: 1.2;
margin-bottom: 12px;
letter-spacing: -0.01em;
}
@media (min-width: 768px) {
.section-title { font-size: 36px; }
}
/* mb-12 */
.section-header { margin-bottom: 48px; }
/* grid grid-cols-1 md:grid-cols-3 gap-6 */
.steps-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 768px) {
.steps-grid { grid-template-columns: 1fr; }
}
/* bg-white rounded-card border border-border p-7 shadow-card */
.step-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 28px;
}
/* text-4xl font-semibold text-border-strong leading-none block mb-4 */
.step-num {
display: block;
font-family: var(--font-heading);
font-size: 36px;
font-weight: 600;
color: var(--color-border-strong);
line-height: 1;
margin-bottom: 16px;
letter-spacing: -0.02em;
}
/* font-heading text-lg text-ink mb-2 */
.step-title {
font-family: var(--font-heading);
font-size: 18px;
font-weight: 600;
color: var(--color-ink);
margin-bottom: 8px;
}
/* text-sm text-ink-muted leading-relaxed */
.step-text {
font-size: 14px;
color: var(--color-ink-muted);
line-height: 1.65;
}
/* ── Footer ──────────────────────────────────────────────── */
/* border-t border-border bg-surface-offset py-8 */
.footer {
background: var(--color-surface-offset);
border-top: 1px solid var(--color-border);
padding: 32px 0;
flex-shrink: 0;
margin-top: auto;
}
.footer__inner {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--color-ink-light);
gap: 12px;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.footer__inner { flex-direction: column; text-align: center; }
}
.footer__links { display: flex; gap: 20px; }
.footer a {
color: var(--color-ink-light);
text-decoration: none;
transition: color var(--transition);
}
.footer a:hover { color: var(--color-accent); }

110
app/server.js Normal file
View File

@@ -0,0 +1,110 @@
'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'))

31
app/src/crypto.js Normal file
View File

@@ -0,0 +1,31 @@
import { AES, Bytes, Hex } from 'occulto'
export async function fetchWithTimeout(url, options, ms = 15000) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), ms)
try {
return await fetch(url, { ...options, signal: ctrl.signal })
} finally {
clearTimeout(timer)
}
}
export async function createNote(text) {
const key = await AES.generateKey()
const encrypted = await AES.encrypt(Bytes.encode(text), key)
const res = await fetchWithTimeout('/api/notes/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: encrypted,
meta: JSON.stringify({ type: 'text' }),
views: 1,
}),
})
if (!res.ok) throw new Error('Geheimnis konnte nicht erstellt werden (Server-Fehler)')
const { id } = await res.json()
return `${location.origin}/note/${id}#${Hex.encode(key)}`
}

62
app/src/customer.js Normal file
View File

@@ -0,0 +1,62 @@
import { createNote, fetchWithTimeout } from './crypto.js'
const form = document.getElementById('form')
const submitBtn = document.getElementById('submit')
const submitLabel = document.getElementById('submit-label')
const stateForm = document.getElementById('state-form')
const stateSuccess = document.getElementById('state-success')
const stateError = document.getElementById('state-error')
const errorMsg = document.getElementById('error-msg')
function setLoading(on) {
submitBtn.disabled = on
submitLabel.textContent = on ? 'Wird übermittelt …' : 'Jetzt sicher senden'
}
form.addEventListener('submit', async (e) => {
e.preventDefault()
const name = document.getElementById('sender-name').value.trim()
const text = document.getElementById('text').value.trim()
if (!name) {
errorMsg.textContent = 'Bitte geben Sie Ihren Namen ein.'
stateError.hidden = false
document.getElementById('sender-name').focus()
return
}
if (!text) {
errorMsg.textContent = 'Bitte geben Sie Ihr Passwort oder Ihre Zugangsdaten ein.'
stateError.hidden = false
document.getElementById('text').focus()
return
}
stateError.hidden = true
setLoading(true)
try {
const url = await createNote(text)
const res = await fetchWithTimeout('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, name }),
}, 10000)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'E-Mail konnte nicht gesendet werden')
}
stateForm.hidden = true
stateSuccess.hidden = false
} catch (err) {
const msg = err.name === 'AbortError'
? 'Zeitüberschreitung – bitte erneut versuchen.'
: (err.message || 'Ein Fehler ist aufgetreten. Bitte erneut versuchen.')
errorMsg.textContent = msg
stateError.hidden = false
setLoading(false)
}
})

54
app/src/link.js Normal file
View File

@@ -0,0 +1,54 @@
import { createNote } from './crypto.js'
const form = document.getElementById('form')
const submitBtn = document.getElementById('submit')
const submitLabel = document.getElementById('submit-label')
const stateForm = document.getElementById('state-form')
const stateResult = document.getElementById('state-result')
const linkOutput = document.getElementById('link-output')
const copyBtn = document.getElementById('copy-btn')
const copyLabel = document.getElementById('copy-label')
const stateError = document.getElementById('state-error')
const errorMsg = document.getElementById('error-msg')
function setLoading(on) {
submitBtn.disabled = on
submitLabel.textContent = on ? 'Wird erstellt …' : 'Link erstellen'
}
form.addEventListener('submit', async (e) => {
e.preventDefault()
const text = document.getElementById('text').value.trim()
if (!text) return
stateError.hidden = true
setLoading(true)
try {
const url = await createNote(text)
linkOutput.value = url
stateForm.hidden = true
stateResult.hidden = false
} catch (err) {
const msg = err.name === 'AbortError'
? 'Zeitüberschreitung – bitte erneut versuchen.'
: (err.message || 'Ein Fehler ist aufgetreten.')
errorMsg.textContent = msg
stateError.hidden = false
setLoading(false)
}
})
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(linkOutput.value)
copyLabel.textContent = 'Kopiert!'
copyBtn.classList.add('btn--copied')
setTimeout(() => {
copyLabel.textContent = 'Kopieren'
copyBtn.classList.remove('btn--copied')
}, 2000)
} catch {
linkOutput.select()
}
})