commit 45d881f9a9006a4f8e21e3094baca35021dbe7ff Author: Lucas Orth Date: Wed Aug 19 19:56:54 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9c4f241 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.git +test +*.md +.gitea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b1d7cb8 --- /dev/null +++ b/.env.example @@ -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/.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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..02a2144 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..daced9b --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..62e424a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..30b6e97 --- /dev/null +++ b/README.md @@ -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/.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 ` + - 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 +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c87add4 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc213d4 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..375afbe --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/ics.ts b/src/ics.ts new file mode 100644 index 0000000..7f35eac --- /dev/null +++ b/src/ics.ts @@ -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' +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..94699aa --- /dev/null +++ b/src/server.ts @@ -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 { + 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 { + 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 }) diff --git a/test/ics.test.js b/test/ics.test.js new file mode 100644 index 0000000..bf7c51b --- /dev/null +++ b/test/ics.test.js @@ -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')) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..380e166 --- /dev/null +++ b/tsconfig.json @@ -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"] +}