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:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
SMTP_HOST=mail.example.de
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@lucas-orth.de
|
||||||
|
SMTP_PASS=geheimes-passwort
|
||||||
|
SMTP_FROM="Lucas Orth <noreply@lucas-orth.de>"
|
||||||
|
RECIPIENT_EMAIL=privat@lucas-orth.de
|
||||||
|
APP_URL=https://secret.lucas-orth.de
|
||||||
61
.gitea/workflows/deploy.yml
Normal file
61
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Intern klonen statt ueber die oeffentliche Domain: Gitea laeuft auf
|
||||||
|
# demselben Host, der Weg ueber die oeffentliche IP findet nicht
|
||||||
|
# zurueck (NAT-Hairpin). Der Job-Container erreicht Gitea ueber das
|
||||||
|
# Netz gitea-ci unter seinem Containernamen.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: http://gitea:3000
|
||||||
|
|
||||||
|
# --- Build & Tests -----------------------------------------------
|
||||||
|
# Laeuft vor dem Deploy: ein roter Bundle-Build darf den Server nicht
|
||||||
|
# anfassen. Der Docker-Build wiederholt die Schritte spaeter im Image.
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Abhaengigkeiten installieren
|
||||||
|
working-directory: app
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Frontend-Bundles bauen
|
||||||
|
working-directory: app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Server-Syntax pruefen
|
||||||
|
working-directory: app
|
||||||
|
run: node --check server.js
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
- name: Tag ermitteln
|
||||||
|
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Ein einziges Repo-Secret DOTENV mit dem kompletten .env-Inhalt
|
||||||
|
# (mehrzeilig). Ueber env: statt direkter Interpolation, damit
|
||||||
|
# Anfuehrungszeichen, Backticks und $ in den Werten unangetastet bleiben.
|
||||||
|
- name: .env aus Secret erzeugen
|
||||||
|
env:
|
||||||
|
DOTENV: ${{ secrets.DOTENV }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOTENV" ]; then
|
||||||
|
echo "Secret DOTENV ist leer oder nicht gesetzt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
umask 077
|
||||||
|
echo "$DOTENV" > .env
|
||||||
|
|
||||||
|
- name: Deployen
|
||||||
|
run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180
|
||||||
|
|
||||||
|
- name: Alte Layer aufraeumen
|
||||||
|
run: docker image prune -f
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Generierte Frontend-Bundles (entstehen beim Build aus src/).
|
||||||
|
app/public/customer.js
|
||||||
|
app/public/link.js
|
||||||
|
|
||||||
|
# Heruntergeladene Vorlage, gehoert nicht ins Projekt-Repo.
|
||||||
|
deploy-kit-main*/
|
||||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Projekthinweise
|
||||||
|
|
||||||
|
## Was das ist
|
||||||
|
|
||||||
|
SendSecret ist ein eigenes Frontend vor [cryptgeon](https://github.com/cupcakearmy/cryptgeon).
|
||||||
|
Kunden geben ein Passwort ein, der Browser verschluesselt es lokal
|
||||||
|
(`occulto`), legt es als Notiz in cryptgeon ab und schickt nur den Link
|
||||||
|
per Mail an `RECIPIENT_EMAIL`. Der Schluessel steht im URL-Fragment und
|
||||||
|
erreicht den Server nie.
|
||||||
|
|
||||||
|
Drei Container: `app` (Express-Wrapper, Mail, Proxy), `cryptgeon`, `redis`.
|
||||||
|
Nur `app` haengt im Web-Netz, die anderen beiden im internen Netz.
|
||||||
|
|
||||||
|
## Wo was liegt
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `app/server.js` | Express: statische Seiten, `/api/send` (Mail), `/healthz`, Catch-all-Proxy auf `http://cryptgeon:8000` |
|
||||||
|
| `app/src/` | Frontend-Quellen, werden von `build.js` (esbuild) nach `app/public/` gebundelt |
|
||||||
|
| `app/public/` | `index.html`, `link.html`, `style.css` (Quellen) + generierte Bundles (nicht im Git) |
|
||||||
|
| `docker-compose.yml` | Stack, Konventionen aus DEPLOY.md |
|
||||||
|
| `.gitea/workflows/deploy.yml` | Tag-Deploy |
|
||||||
|
|
||||||
|
## Beim Aendern beachten
|
||||||
|
|
||||||
|
- **`express.json()` nicht global setzen.** Es wuerde den Body-Stream
|
||||||
|
aufbrauchen, bevor der Proxy ihn an cryptgeon weiterreichen kann. Nur an
|
||||||
|
`/api/send` haengen.
|
||||||
|
- **Neue Routen vor dem Catch-all-Proxy registrieren**, sonst gehen sie an
|
||||||
|
cryptgeon. Betrifft insbesondere `/healthz` - das ist das Deploy-Gate.
|
||||||
|
- **cryptgeon-Version ist gepinnt** (`2.9.3`). Die Bundles verschluesseln mit
|
||||||
|
`occulto` 2.0.6 gegen dieses API-Format (`POST /api/notes/` mit
|
||||||
|
`contents` / `meta` / `views`). Vor einem Upgrade beide Seiten pruefen.
|
||||||
|
- **Redis hat bewusst keine Persistenz** (`--save "" --appendonly no`) und
|
||||||
|
deshalb kein Volume. Notizen sollen einen Neustart nicht ueberleben.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Diese App wird per Tag-Push deployt, nicht manuell hochgeladen.
|
||||||
|
Die verbindlichen Konventionen stehen in **DEPLOY.md**. Vor Aenderungen an
|
||||||
|
`Dockerfile`, `docker-compose.yml` oder `.gitea/workflows/` dort nachsehen.
|
||||||
|
|
||||||
|
Kurzfassung der Regeln, die man leicht bricht:
|
||||||
|
|
||||||
|
- Kein `ports:` im Compose. Der Proxy erreicht den Container ueber das Netz
|
||||||
|
`nginx-proxy-manager_default` unter seinem `container_name` (`sendsecret`).
|
||||||
|
- `name:` im Compose ist Pflicht (fester Projektname), sonst legt der
|
||||||
|
CI-Job einen zweiten Stack an.
|
||||||
|
- Keine relativen Bind-Mounts. Compose laeuft im Job-Container, der Pfad
|
||||||
|
zeigt auf dem Host ins Leere.
|
||||||
|
- Healthcheck ist Pflicht, der Deploy nutzt ihn als Gate.
|
||||||
|
- Secrets kommen aus dem Repo-Secret `DOTENV`, nie in die Compose-Datei.
|
||||||
187
DEPLOY.md
Normal file
187
DEPLOY.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Deployment-Konventionen
|
||||||
|
|
||||||
|
Verbindlich fuer jede App, die auf dem VPS per Tag-Push deployt wird.
|
||||||
|
Abweichungen brauchen einen Grund, der hier nicht steht.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
git tag v1.2.3 && git push --tags
|
||||||
|
-> Gitea Actions (act_runner auf dem VPS)
|
||||||
|
-> npm ci / build / test <- rot bricht ab, nichts wird deployt
|
||||||
|
-> .env aus Repo-Secret DOTENV
|
||||||
|
-> docker compose up -d --build --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein SCP, kein manuelles Neubauen. Der Code landet nie im Dateisystem des
|
||||||
|
Hosts - der Job-Container klont, baut gegen den Docker-Daemon des Hosts und
|
||||||
|
startet den Container.
|
||||||
|
|
||||||
|
## Infrastruktur (existiert bereits, nicht neu anlegen)
|
||||||
|
|
||||||
|
| Ding | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Git | Gitea, `https://gitea.lucas-orth.de`, Container `gitea`, intern `:3000` |
|
||||||
|
| Runner | `act_runner`, Container `gitea-runner`, Label `ubuntu-latest` |
|
||||||
|
| Netz CI | `gitea-ci` - Gitea + Runner + Job-Container |
|
||||||
|
| Netz Web | `nginx-proxy-manager_default` - Proxy + alle App-Container |
|
||||||
|
| Job-Image | `catthehacker/ubuntu:act-latest` (node + docker-cli + compose) |
|
||||||
|
|
||||||
|
Die beiden Netze sind bewusst getrennt: der Runner spricht mit Gitea, nicht
|
||||||
|
mit dem Proxy. App-Container haengen im Web-Netz, nicht im CI-Netz.
|
||||||
|
|
||||||
|
## docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen
|
||||||
|
# ab - und der ist im CI-Job ein anderer als auf dem Host.
|
||||||
|
name: <app>
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# Versionstag ermoeglicht Rollback ohne Rebuild.
|
||||||
|
image: <app>:${IMAGE_TAG:-latest}
|
||||||
|
build: .
|
||||||
|
# Fester Name: das ist die Adresse, auf die der Proxy zeigt.
|
||||||
|
container_name: <app>
|
||||||
|
restart: unless-stopped
|
||||||
|
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
||||||
|
env_file: .env
|
||||||
|
networks:
|
||||||
|
- nginx-proxy-manager_default
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:<port>/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nginx-proxy-manager_default:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
<app>-data:
|
||||||
|
# Fester Name, damit das Volume nicht am Projektnamen haengt.
|
||||||
|
name: <app>-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kein `ports:`.** Der Proxy erreicht den Container ueber das gemeinsame Netz
|
||||||
|
unter seinem Container-Namen. Ein Port-Mapping wuerde den Dienst zusaetzlich
|
||||||
|
offen ans Internet haengen.
|
||||||
|
|
||||||
|
**Healthcheck ist Pflicht.** Der Deploy-Schritt nutzt `--wait` und macht ihn
|
||||||
|
zum Gate. Ohne Healthcheck wird der Lauf gruen, auch wenn der Container in
|
||||||
|
einer Crash-Schleife haengt.
|
||||||
|
|
||||||
|
**Keine relativen Bind-Mounts** (`./data:/data`). Compose laeuft im
|
||||||
|
Job-Container, der Pfad wuerde auf dem Host ins Leere zeigen. Persistente
|
||||||
|
Daten gehoeren in ein named volume mit festem `name:`.
|
||||||
|
|
||||||
|
## .gitea/workflows/deploy.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Intern klonen. Die oeffentliche Domain ist aus Containern heraus
|
||||||
|
# NICHT erreichbar: Gitea laeuft auf demselben Host, das Paket geht
|
||||||
|
# an die oeffentliche IP und findet nicht zurueck (NAT-Hairpin).
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: http://gitea:3000
|
||||||
|
|
||||||
|
# --- Build & Tests, projektspezifisch ---
|
||||||
|
|
||||||
|
- name: Tag ermitteln
|
||||||
|
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: .env aus Secret erzeugen
|
||||||
|
env:
|
||||||
|
DOTENV: ${{ secrets.DOTENV }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOTENV" ]; then
|
||||||
|
echo "Secret DOTENV ist leer oder nicht gesetzt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
umask 077
|
||||||
|
echo "$DOTENV" > .env
|
||||||
|
|
||||||
|
- name: Deployen
|
||||||
|
run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180
|
||||||
|
|
||||||
|
- name: Alte Layer aufraeumen
|
||||||
|
run: docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests gehoeren **vor** den Deploy-Schritt. Ein roter Lauf darf den Server
|
||||||
|
nicht anfassen.
|
||||||
|
|
||||||
|
## Dockerfile
|
||||||
|
|
||||||
|
- Multi-Stage: Build-Abhaengigkeiten landen nicht im finalen Image.
|
||||||
|
- `USER node` bzw. non-root im finalen Stage.
|
||||||
|
- `EXPOSE <port>` dokumentiert den internen Port (oeffnet nichts).
|
||||||
|
- Ein Health-Endpoint (`/healthz`), auf den der Healthcheck zeigt.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Ein einziges Repo-Secret **`DOTENV`** mit dem kompletten `.env`-Inhalt,
|
||||||
|
mehrzeilig. Anlegen unter Repo -> Einstellungen -> Actions -> Secrets.
|
||||||
|
|
||||||
|
Gitea speichert Secrets write-only: nach dem Anlegen nicht mehr lesbar, nur
|
||||||
|
ersetzbar. Die kanonische Fassung gehoert deshalb in den Passwortmanager,
|
||||||
|
Gitea haelt nur die Arbeitskopie fuer die Pipeline.
|
||||||
|
|
||||||
|
Der Wert wird ueber `env:` an die Shell gereicht, nie direkt interpoliert -
|
||||||
|
sonst zerlegt der Shell-Parser Werte mit `$`, Backticks oder Quotes.
|
||||||
|
|
||||||
|
## Nginx Proxy Manager
|
||||||
|
|
||||||
|
Neuer Proxy Host:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Domain Names | `<app>.lucas-orth.de` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | `<app>` (= `container_name`) |
|
||||||
|
| Forward Port | `<port>` |
|
||||||
|
|
||||||
|
Danach Let's-Encrypt-Zertifikat ausstellen und "Force SSL" aktivieren.
|
||||||
|
|
||||||
|
Weil der Container-Name die Adresse ist: aendert sich `container_name`,
|
||||||
|
zeigt der Proxy-Host ins Leere.
|
||||||
|
|
||||||
|
## Bestehende App umstellen
|
||||||
|
|
||||||
|
Laeuft die App heute schon manuell auf dem Server, gilt statt des folgenden
|
||||||
|
Abschnitts [MIGRATION.md](MIGRATION.md) - dort steht insbesondere, wie die
|
||||||
|
Volumes den Wechsel des Projektnamens ueberleben.
|
||||||
|
|
||||||
|
## Erstes Deployment einer App
|
||||||
|
|
||||||
|
1. Repo in Gitea anlegen, pushen.
|
||||||
|
2. Secret `DOTENV` setzen.
|
||||||
|
3. Pruefen, ob schon ein Container mit dem Namen laeuft
|
||||||
|
(`docker ps -a --filter name=<app>`). Falls ja: im alten Verzeichnis
|
||||||
|
einmal `docker compose down`, sonst kollidiert der feste Container-Name.
|
||||||
|
4. Proxy Host im NPM anlegen.
|
||||||
|
5. `git tag v1.0.0 && git push origin v1.0.0`
|
||||||
|
|
||||||
|
## Fallstricke
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| Job haengt beim Checkout | `github-server-url` fehlt, klont ueber die oeffentliche Domain |
|
||||||
|
| `network not found` | App-Container im CI-Netz statt im Web-Netz (oder umgekehrt) |
|
||||||
|
| Zweiter Container statt Update | `name:` im Compose fehlt, Projektname weicht ab |
|
||||||
|
| Lauf gruen, App tot | `--wait` fehlt oder kein Healthcheck definiert |
|
||||||
|
| Volume leer nach Umstellung | Projektname geaendert, altes Volume hiess anders |
|
||||||
|
| Proxy liefert 502 | `container_name` geaendert oder Container nicht im Web-Netz |
|
||||||
95
README.md
Normal file
95
README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# SendSecret
|
||||||
|
|
||||||
|
Sichere Passwort-Uebergabe fuer Kunden. Eigenes Frontend vor
|
||||||
|
[cryptgeon](https://github.com/cupcakearmy/cryptgeon): das Passwort wird im
|
||||||
|
Browser verschluesselt, landet als einmalig abrufbare Notiz in cryptgeon,
|
||||||
|
und per Mail geht nur der Link raus. Der Schluessel steht im
|
||||||
|
URL-Fragment und erreicht den Server nie.
|
||||||
|
|
||||||
|
Zwei Einstiege:
|
||||||
|
|
||||||
|
- `/` - Kundenformular. Verschickt den Link automatisch an `RECIPIENT_EMAIL`.
|
||||||
|
- `/link` - erzeugt nur den Link zum selbst Weitergeben.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser --https--> Nginx Proxy Manager --> sendsecret (app)
|
||||||
|
| / statische Seite
|
||||||
|
| /api/send Mailversand
|
||||||
|
| /healthz Deploy-Gate
|
||||||
|
+--> cryptgeon --> redis
|
||||||
|
```
|
||||||
|
|
||||||
|
`cryptgeon` und `redis` haengen nur im internen Netz `sendsecret-internal`
|
||||||
|
und sind von aussen nicht erreichbar.
|
||||||
|
|
||||||
|
## Lokal entwickeln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app && npm ci && npm run build && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Braucht ein laufendes cryptgeon unter `http://cryptgeon:8000` sowie die
|
||||||
|
Variablen aus `.env.example`. Einfacher ist der komplette Stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env && docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
`.env` im Repo-Root, Vorlage in [.env.example](.env.example):
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` | Mailserver, Port 465 schaltet auf TLS |
|
||||||
|
| `SMTP_USER` / `SMTP_PASS` | Zugangsdaten |
|
||||||
|
| `SMTP_FROM` | Absender im Format `Name <adresse>` |
|
||||||
|
| `RECIPIENT_EMAIL` | Empfaenger der Kundenmails |
|
||||||
|
| `APP_URL` | Oeffentliche Basis-URL, prueft die eingereichten Links |
|
||||||
|
|
||||||
|
Auf dem Server kommt der Inhalt nicht aus einer Datei, sondern aus dem
|
||||||
|
Gitea-Repo-Secret `DOTENV`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Per Tag-Push, Konventionen in [DEPLOY.md](DEPLOY.md).
|
||||||
|
|
||||||
|
Einmalig einzurichten:
|
||||||
|
|
||||||
|
1. Repo-Secret `DOTENV` anlegen (Repo -> Einstellungen -> Actions ->
|
||||||
|
Secrets) mit dem kompletten `.env`-Inhalt, mehrzeilig. Kanonische
|
||||||
|
Fassung in den Passwortmanager - Gitea gibt den Wert nicht mehr heraus.
|
||||||
|
2. Pruefen, ob auf dem Server schon Container mit den festen Namen laufen:
|
||||||
|
`docker ps -a --filter name=sendsecret`. Falls ja, im alten Verzeichnis
|
||||||
|
einmal `docker compose down`, sonst kollidieren die Namen.
|
||||||
|
3. Proxy Host im Nginx Proxy Manager:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Domain Names | `secret.lucas-orth.de` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | `sendsecret` |
|
||||||
|
| Forward Port | `3000` |
|
||||||
|
|
||||||
|
Danach Let's-Encrypt-Zertifikat ausstellen und "Force SSL" aktivieren.
|
||||||
|
4. Deployen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach pruefen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter name=sendsecret --format '{{.Names}}\t{{.Status}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`sendsecret` muss `healthy` zeigen, nicht `Restarting`.
|
||||||
|
|
||||||
|
## Versionen
|
||||||
|
|
||||||
|
`cupcakearmy/cryptgeon` ist auf **2.9.3** gepinnt - das ist derselbe Stand,
|
||||||
|
den `latest` bisher geliefert hat. Die Frontend-Bundles verschluesseln mit
|
||||||
|
`occulto` 2.0.6 gegen dieses API-Format; ein Upgrade betrifft beide Seiten.
|
||||||
6
app/.dockerignore
Normal file
6
app/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
public/customer.js
|
||||||
|
public/link.js
|
||||||
22
app/Dockerfile
Normal file
22
app/Dockerfile
Normal 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
33
app/build.js
Normal 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
1533
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
app/package.json
Normal file
19
app/package.json
Normal 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
171
app/public/index.html
Normal 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 & 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. Benutzername: max@beispiel.de 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 · Einmalig abrufbar · 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 & 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
174
app/public/link.html
Normal 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 & 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 & 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
400
app/public/style.css
Normal 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
110
app/server.js
Normal 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
31
app/src/crypto.js
Normal 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
62
app/src/customer.js
Normal 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
54
app/src/link.js
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen
|
||||||
|
# ab - und der ist im CI-Job ein anderer als auf dem Host.
|
||||||
|
name: sendsecret
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Der Wrapper: eigenes Frontend, Mailversand, Proxy auf cryptgeon.
|
||||||
|
# Das ist der einzige Dienst, den der Nginx Proxy Manager sieht.
|
||||||
|
app:
|
||||||
|
# Versionstag ermoeglicht Rollback ohne Rebuild.
|
||||||
|
image: sendsecret:${IMAGE_TAG:-latest}
|
||||||
|
build: ./app
|
||||||
|
# Fester Name: das ist die Adresse, auf die der Proxy zeigt.
|
||||||
|
container_name: sendsecret
|
||||||
|
restart: unless-stopped
|
||||||
|
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- cryptgeon
|
||||||
|
networks:
|
||||||
|
- nginx-proxy-manager_default
|
||||||
|
- internal
|
||||||
|
# Pflicht: der Deploy-Schritt nutzt --wait und macht das zum Gate.
|
||||||
|
# Abweichung von den 60s der Vorlage: bei --wait-timeout 180 reicht ein
|
||||||
|
# 60s-Intervall nur fuer drei Versuche - der Deploy wuerde beim ersten
|
||||||
|
# Fehlschlag knapp.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Cryptgeon haelt die verschluesselten Notizen. Version gepinnt: die
|
||||||
|
# Frontend-Bundles verschluesseln mit occulto 2.0.6 gegen dieses API-Format.
|
||||||
|
cryptgeon:
|
||||||
|
image: cupcakearmy/cryptgeon:2.9.3
|
||||||
|
container_name: sendsecret-cryptgeon
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://redis/
|
||||||
|
ALLOW_ADVANCED: 'false'
|
||||||
|
# Bewusst nicht im Web-Netz: erreichbar nur ueber den Proxy in app.
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
# Reiner Zwischenspeicher, ohne Persistenz - Notizen sind einmalig
|
||||||
|
# abrufbar und sollen einen Neustart nicht ueberleben. Deshalb kein Volume.
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --save "" --appendonly no
|
||||||
|
container_name: sendsecret-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Kein ports-Mapping: Der Proxy erreicht app ueber das gemeinsame Netz unter
|
||||||
|
# dem Container-Namen. cryptgeon und redis haengen nur im internen Netz.
|
||||||
|
networks:
|
||||||
|
nginx-proxy-manager_default:
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
name: sendsecret-internal
|
||||||
Reference in New Issue
Block a user