busyfeed: Initial commit mit Tag-Deployment

Bestehende App plus Gitea-Actions-Pipeline: Tag v* loest Build, Tests
und Container-Neustart auf dem VPS aus.

- .gitea/workflows/deploy.yml: npm ci/build/test als Gate, danach
  docker compose up -d --build. Die .env wird aus dem Repo-Secret DOTENV
  erzeugt, damit auf dem Host keine Secret-Datei gepflegt werden muss.
- docker-compose.yml: fester Projektname (der CI-Job hat ein anderes
  Arbeitsverzeichnis als der Host), Image mit Versionstag fuer Rollback,
  fester Volume-Name.
- README: Deployment- und Rollback-Abschnitt, Token-Rotation angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:56:54 +02:00
commit 45d881f9a9
14 changed files with 1401 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.env
.git
test
*.md
.gitea

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Beide Tokens erzeugen mit:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
# Bearer-Token fuer POST /api/sync (kennt nur der Kurzbefehl)
SYNC_TOKEN=hier-32-zufaellige-bytes-einsetzen
# Token im Feed-Pfad: https://busy.example.de/feed/<FEED_TOKEN>.ics
# Achtung: Das ist eine Capability-URL, also faktisch ein Passwort.
FEED_TOKEN=hier-32-andere-zufaellige-bytes-einsetzen
# Anzeigename des Kalenders im abonnierenden Client
CAL_NAME=Belegt (Exchange)
DATA_DIR=/data
PORT=8080
LOG_LEVEL=info

View File

@@ -0,0 +1,43 @@
name: Build & Deploy
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm ci
- run: npm run build
# Gate: rote Tests -> kein Deploy. Die Tests sichern die beiden
# nicht verhandelbaren Punkte ab (UTC statt TZID, kein RRULE).
- run: npm test
- name: Tag ermitteln
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
# Das Repo-Secret DOTENV enthaelt den kompletten .env-Inhalt (mehrzeilig).
# Ueber env: statt direkter Interpolation, damit Anfuehrungszeichen,
# Backticks und $ in den Werten nicht vom Shell-Parser angefasst werden.
- name: .env aus Secret erzeugen
env:
DOTENV: ${{ secrets.DOTENV }}
run: |
umask 077
echo "$DOTENV" > .env
- name: Deployen
run: docker compose up -d --build --remove-orphans
- name: Alte Layer aufraeumen
run: docker image prune -f

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

65
CLAUDE.md Normal file
View File

@@ -0,0 +1,65 @@
# CLAUDE.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
RUN mkdir -p /data && chown -R node:node /data
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# busyfeed
Belegtzeiten aus dem Exchange-Kalender auf dem iPhone als ICS-Feed fuer Cal.com.
Nach aussen gehen **ausschliesslich Start- und Endzeitpunkte**. Keine Titel, keine
Orte, keine Teilnehmer, keine Notizen. Das ist keine Einstellung, die man falsch
setzen kann: Der Kurzbefehl liest diese Felder gar nicht erst aus, der Server kann
sie also weder speichern noch ausliefern.
```
iPhone (Kurzbefehl, 4x taeglich) -> POST /api/sync -> GET /feed/<token>.ics -> Cal.com
```
## Server einrichten
Zwei Tokens erzeugen:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" # SYNC_TOKEN
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" # FEED_TOKEN
```
Dann in Gitea ein Repo-Secret `DOTENV` anlegen (Settings -> Actions -> Secrets)
mit dem kompletten `.env`-Inhalt - Vorlage ist `.env.example`. Auf dem Server
liegt keine `.env`; das Deployment erzeugt sie daraus, siehe
[Deployment](#deployment).
Im nginx proxy manager einen Proxy Host anlegen: `busy.example.de` -> `busyfeed:8080`,
Let's-Encrypt-Zertifikat ausstellen, "Force SSL" aktivieren. Das Compose-File hat
bewusst **kein** `ports:`-Mapping - NPM erreicht den Container ueber das gemeinsame
Netz `nginx-proxy-manager_default`, es liegt nichts offen am Host.
## Der Kurzbefehl
Kurzbefehle-App, neuer Kurzbefehl `Belegtzeiten senden`:
1. **Kalendereignisse suchen**
- Filter: `Kalender` ist *[dein Exchange-Kalender]*
- Filter: `Startdatum` ist nach `heute -1 Tag` **und** vor `heute +90 Tage`
- Limit: aus
2. **Wiederholen mit jedem Element**
- `Details von Kalendereignissen abrufen` -> *Startdatum* -> `Datum formatieren` -> ISO 8601
- dasselbe fuer *Enddatum*
- `Text`: `[Startdatum]|[Enddatum]`
3. **Text kombinieren**, Trennzeichen: neue Zeile
4. **Inhalt von URL abrufen**
- URL `https://busy.example.de/api/sync`, Methode `POST`
- Header `Authorization: Bearer <SYNC_TOKEN>`
- Request Body `JSON`, ein Feld `events` (Text) -> die kombinierte Textvariable
Ein einzelnes Textfeld statt eines echten JSON-Arrays ist Absicht: Arrays in
Shortcuts zu bauen ist muehsam und fehleranfaellig.
**Automationen:** Kurzbefehle -> Automation -> Tageszeit, vier Stueck
(z. B. 07:00 / 11:00 / 15:00 / 19:00), jeweils "Vor dem Ausfuehren fragen" **aus**.
Stuendlich geht nicht - Tageszeit-Automationen kennen nur taeglich/woechentlich/monatlich.
## Cal.com verbinden
Settings -> Calendars -> "Check for conflicts" -> +Add -> ICS Feed -> Feed-URL einfuegen.
## Verhalten im Fehlerfall
| Situation | Verhalten |
|---|---|
| Snapshot veraltet | Wird **weiter ausgeliefert**. Ein alter Belegt-Feed ist sicherer als ein leerer - leer hiesse "alles frei" und wuerde Doppelbuchungen verursachen. Das Alter steht im `X-WR-CALDESC`. |
| Sync liefert null Termine | Wird mit **409 abgelehnt**, der alte Snapshot bleibt. Sonst blankt ein kaputter Kurzbefehl den Feed. Bewusstes Leeren: `POST /api/sync?allowEmpty=1`. |
| Einzelne Zeilen unparsbar | Werden uebersprungen und in der Antwort als `skippedLines` zurueckgemeldet. |
| Falsches Feed-Token | `404`, nicht `401` - die Existenz des Feeds soll ohne Token nicht erkennbar sein. |
## Nicht verhandelbar
- **Alle Zeiten als UTC mit `Z`, niemals `TZID`.** Cal.com interpretiert `TZID` falsch
([calcom/cal.com#14664](https://github.com/calcom/cal.com/issues/14664), offen seit
April 2024). Termine landen sonst zur falschen Uhrzeit und es entstehen Doppelbuchungen.
- **Kein `RRULE`.** Der Kurzbefehl liefert Serientermine bereits als Einzelvorkommen.
Beides ist durch Tests abgesichert: `npm test`.
## Sicherheit
Die Feed-URL ist eine **Capability-URL** - ICS-Abos koennen in vielen Clients keine
Authentifizierung, der Schutz ist allein der unratbare Token im Pfad. Diese URL ist
faktisch ein Passwort und gehoert in keinen Chat und kein Ticket. Zum Rotieren:
`FEED_TOKEN` im Secret `DOTENV` aendern, neuen Tag pushen, Abo in Cal.com neu anlegen.
## Deployment
Deployt wird per Tag. Kein SCP, kein manuelles Neubauen.
```bash
git tag v1.0.1
git push origin v1.0.1
```
Der Gitea-Runner auf dem VPS baut, testet und startet den Container neu.
**Rote Tests brechen den Lauf ab, bevor irgendetwas deployt wird.**
Vorausgesetzt: das Repo-Secret `DOTENV` ist gesetzt und auf dem VPS laeuft ein
registrierter `act_runner` mit Zugriff auf den Docker-Socket.
Rollback auf eine aeltere Version, direkt auf dem VPS:
```bash
IMAGE_TAG=v1.0.0 docker compose up -d
```
Ohne `--build` - das alte Image liegt noch lokal.
## Lokal entwickeln
```bash
npm install
npm run build
npm test
SYNC_TOKEN=lokal-test-token-1234 FEED_TOKEN=lokal-feed-token-1234 \
DATA_DIR=./.local-data PORT=8099 node dist/server.js
```

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
# Fester Projektname: sonst leitet Compose ihn aus dem Verzeichnisnamen ab und
# der weicht im CI-Job vom Host ab.
name: busyfeed
services:
busyfeed:
image: busyfeed:${IMAGE_TAG:-latest}
build: .
container_name: busyfeed
restart: unless-stopped
env_file: .env
volumes:
- busyfeed-data:/data
networks:
- nginx-proxy-manager_default
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 60s
timeout: 5s
retries: 3
# Kein ports-Mapping: Der nginx proxy manager erreicht den Container ueber das
# gemeinsame Docker-Netz, es liegt nichts offen am Host.
networks:
nginx-proxy-manager_default:
external: true
volumes:
busyfeed-data:
# Fester Name, damit das Volume nicht am Projektnamen haengt.
name: busyfeed-data

682
package-lock.json generated Normal file
View File

@@ -0,0 +1,682 @@
{
"name": "busyfeed",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "busyfeed",
"version": "1.0.0",
"dependencies": {
"fastify": "^5.2.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
"integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^4.0.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz",
"integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^7.0.0"
}
},
"node_modules/@fastify/forwarded": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz",
"integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@fastify/proxy-addr": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/forwarded": "^3.0.0",
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz",
"integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.0.0",
"fastq": "^1.17.1"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
"integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^4.0.0",
"json-schema-ref-resolver": "^3.0.0",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz",
"integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastify": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.1.tgz",
"integrity": "sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^4.0.5",
"@fastify/error": "^4.0.0",
"@fastify/fast-json-stringify-compiler": "^5.0.0",
"@fastify/proxy-addr": "^5.0.0",
"abstract-logging": "^2.0.1",
"avvio": "^9.0.0",
"fast-json-stringify": "^7.0.0",
"find-my-way": "^9.6.0",
"light-my-request": "^6.0.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.1.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
"semver": "^7.6.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/find-my-way": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.8.0.tgz",
"integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ipaddr.js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
"integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^1.0.1",
"process-warning": "^4.0.0",
"set-cookie-parser": "^2.6.0"
}
},
"node_modules/light-my-request/node_modules/process-warning": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
"integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "busyfeed",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Belegtzeiten vom iPhone als ICS-Feed fuer Cal.com",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsc --watch",
"test": "node --test test/*.test.js"
},
"dependencies": {
"fastify": "^5.2.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

146
src/ics.ts Normal file
View File

@@ -0,0 +1,146 @@
import { createHash } from 'node:crypto'
/** Ein Belegt-Intervall. Zeiten immer als UTC-ISO-String. */
export type Interval = { start: string; end: string }
export type Snapshot = {
/** Zeitpunkt des letzten erfolgreichen Sync, UTC-ISO. */
syncedAt: string
intervals: Interval[]
}
/**
* Shortcuts liefert ISO 8601 je nach Gebietsschema mal mit "+02:00" und mal
* mit "+0200". Node parst zuverlaessig nur die Variante mit Doppelpunkt.
*/
function normalizeOffset(value: string): string {
return value.replace(/([+-]\d{2})(\d{2})$/, '$1:$2')
}
/**
* Parst den Textblock aus dem Kurzbefehl: eine Zeile pro Termin, "start|ende"
* in ISO 8601 mit Zeitzonen-Offset. Unparsbare Zeilen werden zurueckgemeldet
* statt stillschweigend verschluckt - sonst merkt man einen kaputten
* Kurzbefehl erst an fehlenden Belegtzeiten.
*/
export function parseIntervals(raw: string): { intervals: Interval[]; skipped: string[] } {
const intervals: Interval[] = []
const skipped: string[] = []
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed) continue
const parts = trimmed.split('|')
if (parts.length !== 2) {
skipped.push(trimmed)
continue
}
const start = new Date(normalizeOffset(parts[0]!.trim()))
const end = new Date(normalizeOffset(parts[1]!.trim()))
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
skipped.push(trimmed)
continue
}
intervals.push({ start: start.toISOString(), end: end.toISOString() })
}
return { intervals, skipped }
}
/** "2026-08-19T14:00:00.000Z" -> "20260819T140000Z" */
function icsStamp(iso: string): string {
return iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
}
/**
* Deterministische UID aus Start und Ende. Bleibt ueber Syncs hinweg stabil,
* damit abonnierende Clients keine Geisterloeschungen sehen.
*/
function uid(interval: Interval): string {
const hash = createHash('sha256').update(`${interval.start}|${interval.end}`).digest('hex')
return `${hash.slice(0, 32)}@busyfeed`
}
/** RFC 5545 erlaubt maximal 75 Oktette je Zeile, laengere werden gefaltet. */
function fold(line: string): string {
const bytes = Buffer.from(line, 'utf8')
if (bytes.length <= 75) return line
const chunks: string[] = []
let cursor = 0
let limit = 75
while (cursor < bytes.length) {
let take = Math.min(limit, bytes.length - cursor)
// Nicht mitten in ein UTF-8-Zeichen schneiden.
while (take > 0 && cursor + take < bytes.length && (bytes[cursor + take]! & 0xc0) === 0x80) take--
chunks.push(bytes.subarray(cursor, cursor + take).toString('utf8'))
cursor += take
limit = 74 // Folgezeilen beginnen mit einem Leerzeichen.
}
return chunks.join('\r\n ')
}
const BACKSLASH = String.fromCharCode(92)
/** Escaping fuer TEXT-Werte nach RFC 5545: Backslash, Semikolon, Komma, Zeilenumbruch. */
function escapeText(value: string): string {
return value
.split(BACKSLASH).join(BACKSLASH + BACKSLASH)
.split(';').join(BACKSLASH + ';')
.split(',').join(BACKSLASH + ',')
.replace(/\r?\n/g, BACKSLASH + 'n')
}
/**
* Baut die ICS-Datei.
*
* Zwei Regeln sind nicht verhandelbar:
* - Alle Zeiten als UTC mit Z-Suffix, niemals TZID. Cal.com interpretiert
* TZID falsch (offener Bug calcom/cal.com#14664), Termine landen dann zur
* falschen Uhrzeit und es entstehen Doppelbuchungen.
* - Kein RRULE. Der Kurzbefehl liefert Serientermine bereits als einzelne
* Vorkommen, also bleibt es bei einer flachen Liste konkreter Intervalle.
*/
export function buildIcs(snapshot: Snapshot | null, calendarName: string): string {
const description = snapshot ? `Letzter Sync ${snapshot.syncedAt}` : 'Noch kein Sync empfangen'
// DTSTAMP aus dem Snapshot statt aus der aktuellen Zeit: so ist die Ausgabe
// fuer einen gegebenen Snapshot byte-identisch und aendert sich nicht bei
// jedem Abruf.
const stamp = icsStamp(snapshot?.syncedAt ?? new Date(0).toISOString())
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//busyfeed//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${escapeText(calendarName)}`,
`X-WR-CALDESC:${escapeText(description)}`,
'X-PUBLISHED-TTL:PT1H',
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
]
for (const interval of snapshot?.intervals ?? []) {
lines.push(
'BEGIN:VEVENT',
`UID:${uid(interval)}`,
`DTSTAMP:${stamp}`,
`DTSTART:${icsStamp(interval.start)}`,
`DTEND:${icsStamp(interval.end)}`,
'SUMMARY:Belegt',
'TRANSP:OPAQUE',
'STATUS:CONFIRMED',
'CLASS:PRIVATE',
'END:VEVENT',
)
}
lines.push('END:VCALENDAR')
return lines.map(fold).join('\r\n') + '\r\n'
}

118
src/server.ts Normal file
View File

@@ -0,0 +1,118 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import Fastify from 'fastify'
import { buildIcs, parseIntervals, type Snapshot } from './ics.js'
function requireEnv(name: string): string {
const value = process.env[name]
if (!value || value.length < 16) {
throw new Error(`${name} fehlt oder ist kuerzer als 16 Zeichen`)
}
return value
}
const SYNC_TOKEN = requireEnv('SYNC_TOKEN')
const FEED_TOKEN = requireEnv('FEED_TOKEN')
const DATA_DIR = process.env.DATA_DIR ?? '/data'
const PORT = Number(process.env.PORT ?? 8080)
const CAL_NAME = process.env.CAL_NAME ?? 'Belegt (Exchange)'
const SNAPSHOT_PATH = join(DATA_DIR, 'snapshot.json')
/**
* Vergleicht Tokens in konstanter Zeit. Vorheriges Hashen gleicht die Laenge
* an, damit timingSafeEqual nicht bei unterschiedlich langen Eingaben wirft.
*/
function tokenMatches(provided: string, expected: string): boolean {
const a = createHash('sha256').update(provided).digest()
const b = createHash('sha256').update(expected).digest()
return timingSafeEqual(a, b)
}
async function readSnapshot(): Promise<Snapshot | null> {
try {
return JSON.parse(await readFile(SNAPSHOT_PATH, 'utf8')) as Snapshot
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
/** Atomar schreiben, damit ein Absturz mitten im Schreiben keinen halben Snapshot hinterlaesst. */
async function writeSnapshot(snapshot: Snapshot): Promise<void> {
const temporary = `${SNAPSHOT_PATH}.tmp`
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
await rename(temporary, SNAPSHOT_PATH)
}
const app = Fastify({
bodyLimit: 1_000_000,
logger: { level: process.env.LOG_LEVEL ?? 'info' },
})
app.get('/healthz', async () => ({ ok: true }))
app.post('/api/sync', async (request, reply) => {
const header = request.headers.authorization ?? ''
const provided = header.startsWith('Bearer ') ? header.slice(7) : ''
if (!provided || !tokenMatches(provided, SYNC_TOKEN)) {
return reply.code(401).send({ error: 'unauthorized' })
}
const body = request.body as { events?: unknown } | undefined
if (typeof body?.events !== 'string') {
return reply.code(400).send({ error: 'Feld "events" fehlt oder ist kein Text' })
}
const { intervals, skipped } = parseIntervals(body.events)
// Fail closed: Ein leerer Sync darf einen gefuellten Snapshot nicht
// ueberschreiben. Sonst blankt ein kaputter Kurzbefehl den Feed und Cal.com
// haelt den ganzen Kalender fuer frei - genau die gefaehrliche Richtung.
// Bewusstes Leeren geht ueber ?allowEmpty=1.
const allowEmpty = (request.query as { allowEmpty?: string }).allowEmpty === '1'
if (intervals.length === 0 && !allowEmpty) {
const previous = await readSnapshot()
if (previous && previous.intervals.length > 0) {
request.log.warn({ skipped: skipped.length }, 'leerer Sync abgelehnt, alter Snapshot bleibt')
return reply.code(409).send({
error: 'Leerer Sync abgelehnt, vorheriger Snapshot bleibt erhalten',
hint: 'Zum bewussten Leeren ?allowEmpty=1 anhaengen',
keptIntervals: previous.intervals.length,
skipped: skipped.length,
skippedLines: skipped.slice(0, 5),
})
}
}
const snapshot: Snapshot = { syncedAt: new Date().toISOString(), intervals }
await writeSnapshot(snapshot)
request.log.info({ accepted: intervals.length, skipped: skipped.length }, 'sync ok')
return {
accepted: intervals.length,
skipped: skipped.length,
skippedLines: skipped.slice(0, 5),
syncedAt: snapshot.syncedAt,
}
})
app.get('/feed/:file', async (request, reply) => {
const { file } = request.params as { file: string }
// 404 statt 401: Die Existenz des Feeds soll ohne Token nicht erkennbar sein.
if (!file.endsWith('.ics') || !tokenMatches(file.slice(0, -4), FEED_TOKEN)) {
return reply.code(404).send({ error: 'not found' })
}
// Veralteten Snapshot weiter ausliefern statt zu leeren: ein alter
// Belegt-Feed ist sicherer als ein leerer. Das Alter steht im CALDESC.
const snapshot = await readSnapshot()
return reply
.header('content-type', 'text/calendar; charset=utf-8')
.header('cache-control', 'no-cache')
.send(buildIcs(snapshot, CAL_NAME))
})
await mkdir(DATA_DIR, { recursive: true })
await app.listen({ host: '0.0.0.0', port: PORT })

118
test/ics.test.js Normal file
View File

@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildIcs, parseIntervals } from '../dist/ics.js'
const build = (raw, name = 'Belegt (Exchange)') => {
const { intervals } = parseIntervals(raw)
return buildIcs({ syncedAt: '2026-08-19T12:04:11.000Z', intervals }, name)
}
test('rechnet Ortszeit mit Offset korrekt nach UTC', () => {
const { intervals } = parseIntervals('2026-08-19T14:00:00+02:00|2026-08-19T15:30:00+02:00')
assert.equal(intervals.length, 1)
assert.equal(intervals[0].start, '2026-08-19T12:00:00.000Z')
assert.equal(intervals[0].end, '2026-08-19T13:30:00.000Z')
})
test('normalisiert Offset ohne Doppelpunkt (+0200)', () => {
const { intervals } = parseIntervals('2026-08-19T14:00:00+0200|2026-08-19T15:00:00+0200')
assert.equal(intervals[0].start, '2026-08-19T12:00:00.000Z')
})
test('behandelt Winterzeit und Sommerzeit unterschiedlich', () => {
const winter = parseIntervals('2026-01-15T14:00:00+01:00|2026-01-15T15:00:00+01:00').intervals[0]
const sommer = parseIntervals('2026-07-15T14:00:00+02:00|2026-07-15T15:00:00+02:00').intervals[0]
assert.equal(winter.start, '2026-01-15T13:00:00.000Z')
assert.equal(sommer.start, '2026-07-15T12:00:00.000Z')
})
test('ganztaegiger Termin deckt den vollen lokalen Tag ab', () => {
const { intervals } = parseIntervals('2026-08-19T00:00:00+02:00|2026-08-20T00:00:00+02:00')
assert.equal(intervals[0].start, '2026-08-18T22:00:00.000Z')
assert.equal(intervals[0].end, '2026-08-19T22:00:00.000Z')
})
test('ueberspringt kaputte und sinnlose Zeilen, meldet sie zurueck', () => {
const { intervals, skipped } = parseIntervals(
[
'2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00',
'voelliger unsinn',
'2026-08-19T14:00:00+02:00',
'2026-08-19T15:00:00+02:00|2026-08-19T14:00:00+02:00',
'',
].join('\n'),
)
assert.equal(intervals.length, 1)
assert.deepEqual(skipped, [
'voelliger unsinn',
'2026-08-19T14:00:00+02:00',
'2026-08-19T15:00:00+02:00|2026-08-19T14:00:00+02:00',
])
})
test('erzeugt niemals TZID - das ist der Cal.com-Bug', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(!ics.includes('TZID'), 'ICS darf kein TZID enthalten')
assert.ok(!ics.includes('BEGIN:VTIMEZONE'), 'ICS darf keine VTIMEZONE enthalten')
})
test('schreibt alle Zeitstempel als UTC mit Z', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
const stamps = ics.match(/^(DTSTART|DTEND|DTSTAMP):.*$/gm)
assert.equal(stamps.length, 3)
for (const stamp of stamps) {
assert.match(stamp, /:\d{8}T\d{6}Z$/, `nicht UTC: ${stamp}`)
}
assert.ok(ics.includes('DTSTART:20260819T120000Z'))
assert.ok(ics.includes('DTEND:20260819T130000Z'))
})
test('erzeugt kein RRULE', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(!ics.includes('RRULE'))
})
test('gibt ausschliesslich Belegt als Titel aus', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
const summaries = ics.match(/^SUMMARY:.*$/gm)
assert.deepEqual(summaries, ['SUMMARY:Belegt'])
})
test('UID ist deterministisch ueber mehrere Laeufe', () => {
const raw = '2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00'
const first = build(raw).match(/^UID:.*$/m)[0]
const second = build(raw).match(/^UID:.*$/m)[0]
assert.equal(first, second)
})
test('verwendet CRLF als Zeilenende', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(ics.startsWith('BEGIN:VCALENDAR\r\n'))
assert.ok(ics.endsWith('END:VCALENDAR\r\n'))
assert.ok(!/[^\r]\n/.test(ics), 'jedes LF muss ein CR vorangehen')
})
test('escaped Sonderzeichen im Kalendernamen', () => {
const ics = build('', 'Belegt, extern; intern')
const bs = String.fromCharCode(92)
assert.ok(ics.includes(`X-WR-CALNAME:Belegt${bs}, extern${bs}; intern`))
})
test('faltet Zeilen laenger als 75 Oktette', () => {
const ics = build('', 'A'.repeat(200))
for (const line of ics.split('\r\n')) {
assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `zu lang: ${line.length}`)
}
})
test('leerer Snapshot ergibt gueltigen, leeren Kalender', () => {
const ics = buildIcs(null, 'Belegt (Exchange)')
assert.ok(ics.includes('X-WR-CALDESC:Noch kein Sync empfangen'))
assert.ok(!ics.includes('BEGIN:VEVENT'))
assert.ok(ics.includes('END:VCALENDAR'))
})
test('meldet den Sync-Zeitpunkt im CALDESC', () => {
const ics = build('2026-08-19T14:00:00+02:00|2026-08-19T15:00:00+02:00')
assert.ok(ics.includes('X-WR-CALDESC:Letzter Sync 2026-08-19T12:04:11.000Z'))
})

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": false
},
"include": ["src"]
}