Save tokens

This commit is contained in:
Wolfgang Kulhanek
2025-10-16 10:51:40 +02:00
parent 01f0dc942b
commit fee5f74a2c
7 changed files with 366 additions and 38 deletions

187
CLAUDE.md Normal file
View File

@@ -0,0 +1,187 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
bonob is a Sonos SMAPI (Sonos Music API) implementation that bridges Subsonic API clones (like Navidrome and Gonic) with Sonos devices. It acts as a middleware service that translates between the Subsonic API and Sonos's proprietary music service protocol, allowing users to stream their personal music libraries to Sonos speakers.
## Development Commands
### Building and Running
```bash
# Build TypeScript to JavaScript
npm run build
# Development mode with auto-reload (requires environment variables)
npm run dev
# OR with auto-registration
npm run devr
# Register bonob service with Sonos devices
npm run register-dev
```
### Testing
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run testw
# Set custom test timeout (default: 5000ms)
JEST_TIMEOUT=10000 npm test
```
### Environment Variables for Development
When running locally, you need to set several environment variables:
- `BNB_DEV_HOST_IP`: Your machine's IP address (so Sonos can reach bonob)
- `BNB_DEV_SONOS_DEVICE_IP`: IP address of a Sonos device for discovery
- `BNB_DEV_SUBSONIC_URL`: URL of your Subsonic API server (e.g., Navidrome)
## Architecture
### Core Components
**`src/app.ts`** - Application entry point
- Reads configuration from environment variables
- Initializes all services (Subsonic, Sonos, authentication)
- Wires together the Express server with appropriate dependencies
- Handles SIGTERM for graceful shutdown
**`src/server.ts`** - Express HTTP server
- Serves web UI for service registration and login
- Handles music streaming (`/stream/track/:id`)
- Generates icons and cover art (`/icon/...`, `/art/...`)
- Serves Sonos-specific XML files (strings, presentation map)
- Binds SOAP service for Sonos SMAPI communication
**`src/smapi.ts`** - Sonos SMAPI SOAP implementation (1200+ lines)
- Implements the Sonos Music API Protocol via SOAP/XML
- Core operations: `getMetadata`, `getMediaURI`, `search`, `getExtendedMetadata`
- Handles authentication flow with link codes and device auth tokens
- Manages token refresh and session management
- Maps music library concepts to Sonos browse hierarchy
**`src/subsonic.ts`** - Subsonic API client
- Implements `MusicService` and `MusicLibrary` interfaces
- Handles authentication with Subsonic servers using token-based auth
- Translates between Subsonic data models and bonob's domain types
- Supports custom player configurations for transcoding
- Special handling for Navidrome (bearer token authentication)
- Implements artist image fetching with optional caching
**`src/music_service.ts`** - Core domain types and interfaces
- Defines `MusicService` interface (auth and login)
- Defines `MusicLibrary` interface (browsing, search, streaming, rating, scrobbling)
- Domain types: `Artist`, `Album`, `Track`, `Playlist`, `Genre`, `Rating`, etc.
- Uses `fp-ts` for functional programming patterns (`TaskEither`, `Option`, `Either`)
**`src/sonos.ts`** - Sonos device discovery and registration
- Discovers Sonos devices on the network using SSDP/UPnP
- Registers/unregisters bonob as a music service with Sonos systems
- Supports both auto-discovery and seed-host based discovery
- Uses `@svrooij/sonos` library for device communication
**`src/smapi_auth.ts`** - Authentication token management
- Implements JWT-based SMAPI tokens (token + key pairs)
- Handles token verification and expiry
- Token refresh flow using `fp-ts` `TaskEither`
**`src/config.ts`** - Configuration management
- Reads and validates environment variables (all prefixed with `BNB_`)
- Legacy environment variable support (BONOB_ prefix)
- Type-safe configuration with defaults
### Key Abstractions
**BUrn (Bonob URN)** - Resource identifier system (`src/burn.ts`)
- Format: `{ system: string, resource: string }`
- Systems: `subsonic` (for cover art), `external` (for URLs like Spotify images)
- Used for abstracting art/image sources across different backends
**URL Builder** (`src/url_builder.ts`)
- Wraps URL manipulation with a builder pattern
- Handles context path for reverse proxy deployments
- Used throughout for generating URLs that Sonos devices can access
**Custom Players** (`src/subsonic.ts`)
- Allows mime-type specific transcoding configurations
- Maps source mime types to transcoded types
- Creates custom "client" names in Subsonic (e.g., "bonob+audio/flac")
- Example: `BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac>audio/mp3"`
### Data Flow
1. **Sonos App Request** → SOAP endpoint (`/ws/sonos`)
2. **SOAP Service** → Verifies auth token, calls `MusicLibrary` methods
3. **MusicLibrary** → Makes Subsonic API calls, transforms data
4. **SOAP Response** → Returns XML formatted for Sonos
For streaming:
1. **Sonos Device**`GET /stream/track/:id` with custom headers (bnbt, bnbk)
2. **Stream Handler** → Verifies token, calls `MusicLibrary.stream()`
3. **Subsonic Stream** → Proxies audio with proper mime-type handling
4. **Response** → Streams audio to Sonos, reports "now playing"
### Icon System (`src/icon.ts`)
- SVG-based icon generation with dynamic colors
- Supports foreground/background color customization via `BNB_ICON_FOREGROUND_COLOR` and `BNB_ICON_BACKGROUND_COLOR`
- Genre-specific icons
- Text overlay support (e.g., year icons like "1984")
- Holiday/festival decorations (auto-applied based on date)
- Legacy mode: renders to 80x80 PNG for older Sonos systems
### Authentication Flow
1. Sonos app requests link code via `getAppLink()`
2. User visits login URL with link code
3. User enters Subsonic credentials
4. bonob validates with Subsonic, generates service token
5. bonob associates link code with service token
6. Sonos polls `getDeviceAuthToken()` with link code
7. bonob returns SMAPI token (JWT) to Sonos
8. Subsequent requests use SMAPI token, which maps to service token
### Testing Philosophy
- Jest with ts-jest preset
- In-memory implementations for `LinkCodes`, `APITokens` for testing
- Mocking with `ts-mockito`
- Test helpers in `tests/` directory
- Console.log suppressed in tests (see `tests/setup.js`)
## Common Patterns
### Error Handling
- Use `fp-ts` `TaskEither<AuthFailure, T>` for async operations that can fail with auth errors
- SOAP faults for Sonos-specific errors (see SMAPI_FAULT_* constants)
- Promise-based error handling with `.catch()` for most async operations
### Type Safety
- Strict TypeScript (`strict: true`, `noImplicitAny: true`, `noUncheckedIndexedAccess: true`)
- Extensive use of discriminated unions
- Interface-based design for pluggable services
### Logging
- Winston-based logger (`src/logger.ts`)
- Log level controlled by `BNB_LOG_LEVEL`
- Request logging optional via `BNB_SERVER_LOG_REQUESTS`
### Functional Programming
- Heavy use of `fp-ts` for `Option`, `Either`, `TaskEither`
- Pipe-based composition (`pipe(data, fn1, fn2, ...)`)
- Immutable data transformations
## File Organization
- `src/` - TypeScript source code
- `tests/` - Jest test files (mirrors src/ structure)
- `build/` - Compiled JavaScript (gitignored)
- `web/` - HTML templates (Eta templating) and static assets
- `typings/` - Custom TypeScript definitions
## Important Constraints
- bonob must be accessible from Sonos devices at `BNB_URL`
- `BNB_URL` cannot contain "localhost" (validation error)
- Sonos requires specific XML formats (SMAPI WSDL v1.19.6)
- Streaming must handle HTTP range requests for seek functionality
- Token lifetime (`BNB_AUTH_TIMEOUT`) should be less than Subsonic session timeout

62
log.txt Normal file
View File

@@ -0,0 +1,62 @@
{"level":"info","message":"Starting bonob with config {\"port\":8200,\"bonobUrl\":{\"url\":\"https://bonob.famkulhanek.com/\"},\"secret\":\"*******\",\"authTimeout\":\"1h\",\"icons\":{\"foregroundColor\":\"black\",\"backgroundColor\":\"#65d7f4\"},\"logRequests\":true,\"sonos\":{\"serviceName\":\"Kulhanek\",\"discovery\":{\"enabled\":false},\"autoRegister\":false,\"sid\":114316248},\"subsonic\":{\"url\":{\"url\":\"https://music.famkulhanek.com/\"}},\"scrobbleTracks\":true,\"reportNowPlaying\":true}","service":"bonob","timestamp":"2025-10-16 09:45:13"}
{"level":"info","message":"Listening on 8200 available @ https://bonob.famkulhanek.com/","service":"bonob","timestamp":"2025-10-16 09:45:13"}
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:46"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider></credentials></s:Header><s:Body><getAppLink xmlns=\"http://www.sonos.com/Services/1.1\"><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId><hardware>iPhone14,3</hardware><osVersion>Version 26.0.1 (Build 23A355)</osVersion><sonosAppName>ICRU_iPhone14,3</sonosAppName><callbackPath>sonos-2://x-callback-url/addAccount?state=intId%3Dcom%2Efamkulhanek%2Emusic</callbackPath></getAppLink></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:46"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:46"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:46"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><getAppLinkResponse xmlns=\"http://www.sonos.com/Services/1.1\"><getAppLinkResult><authorizeAccount><appUrlStringId>AppLinkMessage</appUrlStringId><deviceLink><regUrl>https://bonob.famkulhanek.com/login?linkCode=42504097-231c-4abb-a878-cecffcc0666f</regUrl><linkCode>42504097-231c-4abb-a878-cecffcc0666f</linkCode><showLinkCode>false</showLinkCode></deviceLink></authorizeAccount></getAppLinkResult></getAppLinkResponse></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:46"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:45:46 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":"/login (req[accept-language]=de-DE,de;q=0.9)","service":"bonob","timestamp":"2025-10-16 09:45:49"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:45:49 +0000] "GET /login?linkCode=42504097-231c-4abb-a878-cecffcc0666f HTTP/1.1" 200 1240 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:51"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider></credentials></s:Header><s:Body><getDeviceAuthToken xmlns=\"http://www.sonos.com/Services/1.1\"><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId><linkCode>42504097-231c-4abb-a878-cecffcc0666f</linkCode></getDeviceAuthToken></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:51"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:51"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:51"}
{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:45:51"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><soap:Fault><faultcode>Client.NOT_LINKED_RETRY</faultcode><faultstring>Link Code not found yet, sonos app will keep polling until you log in to bonob</faultstring><detail><ExceptionInfo>NOT_LINKED_RETRY</ExceptionInfo><SonosError>5</SonosError></detail></soap:Fault></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:51"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:45:51 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:57"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider></credentials></s:Header><s:Body><getDeviceAuthToken xmlns=\"http://www.sonos.com/Services/1.1\"><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId><linkCode>42504097-231c-4abb-a878-cecffcc0666f</linkCode></getDeviceAuthToken></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:57"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:57"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:57"}
{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:45:57"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><soap:Fault><faultcode>Client.NOT_LINKED_RETRY</faultcode><faultstring>Link Code not found yet, sonos app will keep polling until you log in to bonob</faultstring><detail><ExceptionInfo>NOT_LINKED_RETRY</ExceptionInfo><SonosError>5</SonosError></detail></soap:Fault></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:57"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:45:57 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:02"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider></credentials></s:Header><s:Body><getDeviceAuthToken xmlns=\"http://www.sonos.com/Services/1.1\"><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId><linkCode>42504097-231c-4abb-a878-cecffcc0666f</linkCode></getDeviceAuthToken></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:02"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:02"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:02"}
{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:46:02"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><soap:Fault><faultcode>Client.NOT_LINKED_RETRY</faultcode><faultstring>Link Code not found yet, sonos app will keep polling until you log in to bonob</faultstring><detail><ExceptionInfo>NOT_LINKED_RETRY</ExceptionInfo><SonosError>5</SonosError></detail></soap:Fault></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:02"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:46:02 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:07"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider></credentials></s:Header><s:Body><getDeviceAuthToken xmlns=\"http://www.sonos.com/Services/1.1\"><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId><linkCode>42504097-231c-4abb-a878-cecffcc0666f</linkCode></getDeviceAuthToken></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:07"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:07"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:07"}
{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:46:07"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><soap:Fault><faultcode>Client.NOT_LINKED_RETRY</faultcode><faultstring>Link Code not found yet, sonos app will keep polling until you log in to bonob</faultstring><detail><ExceptionInfo>NOT_LINKED_RETRY</ExceptionInfo><SonosError>5</SonosError></detail></soap:Fault></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:07"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:46:07 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":"/login (req[accept-language]=de-DE,de;q=0.9)","service":"bonob","timestamp":"2025-10-16 09:46:08"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:46:09 +0000] "POST /login HTTP/1.1" 200 817 "https://bonob.famkulhanek.com/login?linkCode=42504097-231c-4abb-a878-cecffcc0666f" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:13"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider></credentials></s:Header><s:Body><getDeviceAuthToken xmlns=\"http://www.sonos.com/Services/1.1\"><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId><linkCode>42504097-231c-4abb-a878-cecffcc0666f</linkCode></getDeviceAuthToken></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:13"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:13"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:13"}
{"level":"debug","message":"Adding token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0 {\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0\",\"key\":\"64a930fd-475d-4285-85b1-185f1d358453\"}","service":"bonob","timestamp":"2025-10-16 09:46:13"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><getDeviceAuthTokenResponse xmlns=\"http://www.sonos.com/Services/1.1\"><getDeviceAuthTokenResult><authToken>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0</authToken><privateKey>64a930fd-475d-4285-85b1-185f1d358453</privateKey><userInfo><nickname>wolfgang</nickname><userIdHashCode>86c12ba6737d0873c383445f01db4c6c691579efc0110dd4537bc34b7f5e3e6d</userIdHashCode></userInfo></getDeviceAuthTokenResult></getDeviceAuthTokenResponse></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:13"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:46:13 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:17"}
{"level":"debug","message":{"data":"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"><s:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceId>00-00-00-00-00-00:0</deviceId><deviceProvider>Sonos</deviceProvider><loginToken><token>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0</token><key>64a930fd-475d-4285-85b1-185f1d358453</key><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId></loginToken></credentials></s:Header><s:Body><reportAccountAction xmlns=\"http://www.sonos.com/Services/1.1\"><type>addAccount</type></reportAccountAction></s:Body></s:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:17"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:17"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:17"}
{"level":"info","message":"Sonos reportAccountAction: {\"type\":\"addAccount\"} Headers: {\"host\":\"bonob.famkulhanek.com\",\"user-agent\":\"Linux UPnP/1.0 Sonos/91.0-70070\",\"content-length\":\"1242\",\"accept\":\"text/xml, text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2\",\"accept-encoding\":\"gzip\",\"cache-control\":\"no-cache\",\"content-type\":\"text/xml; charset=utf-8\",\"pragma\":\"no-cache\",\"soapaction\":\"\\\"http://www.sonos.com/Services/1.1#reportAccountAction\\\"\",\"x-forwarded-for\":\"44.205.206.64\",\"x-forwarded-host\":\"bonob.famkulhanek.com\",\"x-forwarded-port\":\"443\",\"x-forwarded-proto\":\"https\",\"x-forwarded-server\":\"ea7d24592c56\",\"x-real-ip\":\"44.205.206.64\"}","service":"bonob","timestamp":"2025-10-16 09:46:17"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><reportAccountActionResponse xmlns=\"http://www.sonos.com/Services/1.1\"></reportAccountActionResponse></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:17"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:46:17 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070"
{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:23"}
{"level":"debug","message":{"data":"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:ns=\"http://www.sonos.com/Services/1.1\"><soap:Header><credentials xmlns=\"http://www.sonos.com/Services/1.1\"><deviceProvider>Sonos</deviceProvider><loginToken><token>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0</token><householdId>Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1</householdId></loginToken></credentials></soap:Header><soap:Body><ns:getMetadata xmlns=\"http://www.sonos.com/Services/1.1\"><id>root</id><index>0</index><count>100</count></ns:getMetadata></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:23"}
{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:23"}
{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:23"}
{"level":"debug","message":"getCredentialsForToken called with: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0","service":"bonob","timestamp":"2025-10-16 09:46:23"}
{"level":"debug","message":"Current tokens: {\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0\",\"key\":\"64a930fd-475d-4285-85b1-185f1d358453\"}}","service":"bonob","timestamp":"2025-10-16 09:46:23"}
{"level":"debug","message":{"data":"<?xml version=\"1.0\" encoding=\"utf-8\"?><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:tns=\"http://www.sonos.com/Services/1.1\"><soap:Body><soap:Fault><faultcode>Client.LoginUnauthorized</faultcode><faultstring>Failed to authenticate, try Re-Authorising your account in the sonos app</faultstring></soap:Fault></soap:Body></soap:Envelope>","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:23"}
::ffff:10.88.0.1 - - [16/Oct/2025:07:46:23 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "com.sonos.SonosController2/80.30 iPhone14,3 iOS/26.0.1 CFNetwork/1.0 Darwin/25.0.0 (ICRU_iPhone14,3) (Sonos/Universal-Content-Service 1.1.998)"

25
package-lock.json generated
View File

@@ -3024,31 +3024,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",

View File

@@ -18,6 +18,7 @@ import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service"; import { MusicService } from "./music_service";
import { SystemClock } from "./clock"; import { SystemClock } from "./clock";
import { JWTSmapiLoginTokens } from "./smapi_auth"; import { JWTSmapiLoginTokens } from "./smapi_auth";
import { FileSmapiTokenStore } from "./smapi_token_store";
const config = readConfig(); const config = readConfig();
const clock = SystemClock; const clock = SystemClock;
@@ -95,7 +96,8 @@ const app = server(
logRequests: config.logRequests, logRequests: config.logRequests,
version, version,
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout), smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
externalImageResolver: artistImageFetcher externalImageResolver: artistImageFetcher,
smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json")
} }
); );

View File

@@ -39,6 +39,7 @@ import {
JWTSmapiLoginTokens, JWTSmapiLoginTokens,
SmapiAuthTokens, SmapiAuthTokens,
} from "./smapi_auth"; } from "./smapi_auth";
import { SmapiTokenStore, InMemorySmapiTokenStore } from "./smapi_token_store";
export const BONOB_ACCESS_TOKEN_HEADER = "bat"; export const BONOB_ACCESS_TOKEN_HEADER = "bat";
@@ -92,6 +93,7 @@ export type ServerOpts = {
version: string; version: string;
smapiAuthTokens: SmapiAuthTokens; smapiAuthTokens: SmapiAuthTokens;
externalImageResolver: ImageFetcher; externalImageResolver: ImageFetcher;
smapiTokenStore: SmapiTokenStore;
}; };
const DEFAULT_SERVER_OPTS: ServerOpts = { const DEFAULT_SERVER_OPTS: ServerOpts = {
@@ -108,6 +110,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
"1m" "1m"
), ),
externalImageResolver: axiosImageFetcher, externalImageResolver: axiosImageFetcher,
smapiTokenStore: new InMemorySmapiTokenStore(),
}; };
function server( function server(
@@ -607,7 +610,8 @@ function server(
apiTokens, apiTokens,
clock, clock,
i8n, i8n,
serverOpts.smapiAuthTokens serverOpts.smapiAuthTokens,
serverOpts.smapiTokenStore
); );
if (serverOpts.applyContextPath) { if (serverOpts.applyContextPath) {

View File

@@ -40,6 +40,7 @@ import {
} from "./smapi_auth"; } from "./smapi_auth";
import { InvalidTokenError } from "./smapi_auth"; import { InvalidTokenError } from "./smapi_auth";
import { IncomingHttpHeaders } from "http2"; import { IncomingHttpHeaders } from "http2";
import { SmapiTokenStore } from "./smapi_token_store";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
export const CREATE_REGISTRATION_ROUTE = "/registration/add"; export const CREATE_REGISTRATION_ROUTE = "/registration/add";
@@ -164,20 +165,20 @@ class SonosSoap {
bonobUrl: URLBuilder; bonobUrl: URLBuilder;
smapiAuthTokens: SmapiAuthTokens; smapiAuthTokens: SmapiAuthTokens;
clock: Clock; clock: Clock;
tokens: {[tokenKey:string]:SmapiToken}; tokenStore: SmapiTokenStore;
constructor( constructor(
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
linkCodes: LinkCodes, linkCodes: LinkCodes,
smapiAuthTokens: SmapiAuthTokens, smapiAuthTokens: SmapiAuthTokens,
clock: Clock clock: Clock,
tokenStore: SmapiTokenStore
) { ) {
this.bonobUrl = bonobUrl; this.bonobUrl = bonobUrl;
this.linkCodes = linkCodes; this.linkCodes = linkCodes;
this.smapiAuthTokens = smapiAuthTokens; this.smapiAuthTokens = smapiAuthTokens;
this.clock = clock; this.clock = clock;
this.tokens = {}; this.tokenStore = tokenStore;
} }
getAppLink(): GetAppLinkResult { getAppLink(): GetAppLinkResult {
@@ -244,17 +245,17 @@ class SonosSoap {
}; };
} }
} }
getCredentialsForToken(token: string): SmapiToken { getCredentialsForToken(token: string): SmapiToken | undefined {
logger.debug("getCredentialsForToken called with: " + token); logger.debug("getCredentialsForToken called with: " + token);
logger.debug("Current tokens: " + JSON.stringify(this.tokens)); logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll()));
return this.tokens[token]!; return this.tokenStore.get(token);
} }
associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken, oldToken?:string) { associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken, oldToken?:string) {
logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken)); logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken));
if(oldToken) { if(oldToken) {
delete this.tokens[oldToken]; this.tokenStore.delete(oldToken);
} }
this.tokens[token] = fullSmapiToken; this.tokenStore.set(token, fullSmapiToken);
} }
} }
@@ -403,9 +404,10 @@ function bindSmapiSoapServiceToExpress(
apiKeys: APITokens, apiKeys: APITokens,
clock: Clock, clock: Clock,
i8n: I8N, i8n: I8N,
smapiAuthTokens: SmapiAuthTokens smapiAuthTokens: SmapiAuthTokens,
tokenStore: SmapiTokenStore
) { ) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock); const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore);
const urlWithToken = (accessToken: string) => const urlWithToken = (accessToken: string) =>
bonobUrl.append({ bonobUrl.append({

96
src/smapi_token_store.ts Normal file
View File

@@ -0,0 +1,96 @@
import fs from "fs";
import path from "path";
import logger from "./logger";
import { SmapiToken } from "./smapi_auth";
export interface SmapiTokenStore {
get(token: string): SmapiToken | undefined;
set(token: string, fullSmapiToken: SmapiToken): void;
delete(token: string): void;
getAll(): { [tokenKey: string]: SmapiToken };
}
export class InMemorySmapiTokenStore implements SmapiTokenStore {
private tokens: { [tokenKey: string]: SmapiToken } = {};
get(token: string): SmapiToken | undefined {
return this.tokens[token];
}
set(token: string, fullSmapiToken: SmapiToken): void {
this.tokens[token] = fullSmapiToken;
}
delete(token: string): void {
delete this.tokens[token];
}
getAll(): { [tokenKey: string]: SmapiToken } {
return this.tokens;
}
}
export class FileSmapiTokenStore implements SmapiTokenStore {
private tokens: { [tokenKey: string]: SmapiToken } = {};
private readonly filePath: string;
constructor(filePath: string) {
this.filePath = filePath;
this.loadFromFile();
}
private loadFromFile(): void {
try {
// Ensure the directory exists
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
logger.info(`Created token storage directory: ${dir}`);
}
// Load existing tokens if file exists
if (fs.existsSync(this.filePath)) {
const data = fs.readFileSync(this.filePath, "utf8");
this.tokens = JSON.parse(data);
logger.info(
`Loaded ${Object.keys(this.tokens).length} token(s) from ${this.filePath}`
);
} else {
logger.info(`No existing token file found at ${this.filePath}, starting fresh`);
this.tokens = {};
this.saveToFile();
}
} catch (error) {
logger.error(`Failed to load tokens from ${this.filePath}`, { error });
this.tokens = {};
}
}
private saveToFile(): void {
try {
const data = JSON.stringify(this.tokens, null, 2);
fs.writeFileSync(this.filePath, data, "utf8");
logger.debug(`Saved ${Object.keys(this.tokens).length} token(s) to ${this.filePath}`);
} catch (error) {
logger.error(`Failed to save tokens to ${this.filePath}`, { error });
}
}
get(token: string): SmapiToken | undefined {
return this.tokens[token];
}
set(token: string, fullSmapiToken: SmapiToken): void {
this.tokens[token] = fullSmapiToken;
this.saveToFile();
}
delete(token: string): void {
delete this.tokens[token];
this.saveToFile();
}
getAll(): { [tokenKey: string]: SmapiToken } {
return this.tokens;
}
}