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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
test
|
||||||
|
*.md
|
||||||
|
.gitea
|
||||||
16
.env.example
Normal file
16
.env.example
Normal 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
|
||||||
43
.gitea/workflows/deploy.yml
Normal file
43
.gitea/workflows/deploy.yml
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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
17
Dockerfile
Normal 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
118
README.md
Normal 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
31
docker-compose.yml
Normal 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
682
package-lock.json
generated
Normal 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
20
package.json
Normal 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
146
src/ics.ts
Normal 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
118
src/server.ts
Normal 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
118
test/ics.test.js
Normal 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
17
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user