Compare commits

...

6 Commits

Author SHA1 Message Date
Wolfgang Kulhanek
1fd8e13668 Playlist update again 2025-10-22 18:25:15 +02:00
Wolfgang Kulhanek
ba52f201b9 Another attempt to fix playlists 2025-10-22 18:15:17 +02:00
Wolfgang Kulhanek
d69b442019 Change playlist handling 2025-10-22 17:53:46 +02:00
Wolfgang Kulhanek
53021a9da1 Fix tests 2025-10-22 17:33:00 +02:00
Wolfgang Kulhanek
f8ff9f30fb Add BNB_TOKEN_CLEANUP_INTERVAL variable and re-design login page 2025-10-22 17:24:56 +02:00
Wolfgang Kulhanek
f08004a4f1 Replace json config with SQLite database 2025-10-22 16:51:40 +02:00
18 changed files with 1227 additions and 94 deletions

91
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,91 @@
# Bonob Source Code Documentation
This document provides an overview of the source files in the `src` directory, explaining the purpose and functionality of each.
### `api_tokens.ts`
Manages API tokens for authentication. It includes an in-memory implementation for storing and retrieving tokens, using SHA256 to mint new tokens.
### `app.ts`
This is the main entry point of the application. It initializes the server, configures the music service (Subsonic), and sets up the integration with Sonos. It reads the application config, sets up the Subsonic connection, and starts the Express server.
### `b64.ts`
Provides simple utility functions for Base64 encoding and decoding of strings.
### `burn.ts`
Handles the creation and parsing of "Bonob URNs" (BURNs), which are unique resource identifiers used within the system. It supports encryption and shorthand notations for more compact URNs.
### `clock.ts`
Provides an abstraction for time-related functions, which is useful for testing. It includes a `SystemClock` that uses the actual system time and a `FixedClock` for tests. It also contains logic for detecting special dates like Christmas and Halloween for seasonal features.
### `config.ts`
Manages the application's configuration by reading environment variables. It defines settings for the server, Sonos integration, Subsonic connection, and other features like scrobbling.
### `encryption.ts`
Implements encryption and decryption functionality using JSON Web Signatures (JWS). It provides a simple interface for encrypting and decrypting strings.
### `i8n.ts`
Handles internationalization (i18n) by providing translations for different languages supported by the Sonos app. It includes translations for UI elements and messages.
### `icon.ts`
Manages SVG icons used in the Sonos app. It allows for transformations like changing colors and applying special styles for festivals and holidays.
### `link_codes.ts`
Implements a system for linking Sonos devices with user accounts. It generates temporary codes that users can use to log in and associate their accounts.
### `logger.ts`
Configures the application-wide logger using Winston. It sets up logging levels and formats.
### `music_service.ts`
Defines the interfaces for a generic music service. This includes methods for authentication, browsing content (artists, albums, tracks), streaming audio, and managing playlists.
### `register.ts`
A command-line script used to register the Bonob service with Sonos devices on the local network.
### `registrar.ts`
Contains the core logic for registering the Bonob service with Sonos devices. It fetches service details from the Bonob server and sends the registration request to a Sonos device.
### `server.ts`
Sets up the Express web server. It defines the routes for the web interface, the Sonos Music API (SMAPI) endpoints, and audio streaming. It also handles user authentication and session management.
### `smapi_auth.ts`
Handles authentication for the Sonos Music API (SMAPI). It is responsible for issuing and verifying JWTs (JSON Web Tokens) that secure the communication between Sonos devices and the Bonob server.
### `smapi_token_store.ts`
Provides an interface and two implementations (in-memory and file-based) for storing SMAPI authentication tokens. This allows the server to persist user sessions.
### `smapi.ts`
Implements the Sonos Music API (SMAPI) using SOAP. This file is responsible for handling all the requests from Sonos devices, such as browsing music, searching, and getting track metadata.
### `sonos.ts`
Manages interactions with Sonos devices on the local network. This includes device discovery and service registration.
### `subsonic.ts`
Implements the `MusicService` interface for Subsonic-compatible media servers (like Navidrome). It handles all communication with the Subsonic API to fetch music data and stream audio.
### `url_builder.ts`
A utility class for building and manipulating URLs in a structured way.
### `utils.ts`
Contains miscellaneous utility functions used throughout the application, such as a function for tidying up XML strings.

384
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@types/xmldom": "^0.1.34",
"@xmldom/xmldom": "^0.9.7",
"axios": "^1.7.8",
"better-sqlite3": "^12.4.1",
"dayjs": "^1.11.13",
"eta": "^2.2.0",
"express": "^4.18.3",
@@ -44,6 +45,7 @@
"xpath": "^0.0.34"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/mocha": "^10.0.10",
@@ -1592,6 +1594,16 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@@ -2178,6 +2190,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -2194,6 +2226,20 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/better-sqlite3": {
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -2203,6 +2249,26 @@
"node": ">=8"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/blob-util": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz",
@@ -2328,6 +2394,30 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -2490,6 +2580,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@@ -2772,6 +2868,21 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
@@ -2795,6 +2906,15 @@
"node": ">=6"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3024,6 +3144,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -3155,6 +3284,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
@@ -3320,6 +3458,12 @@
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -3485,6 +3629,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
@@ -3605,6 +3755,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3799,6 +3955,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -3897,6 +4073,12 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/iobuffer": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.3.2.tgz",
@@ -5193,6 +5375,18 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -5210,6 +5404,21 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ml-array-max": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz",
@@ -5482,6 +5691,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5508,6 +5723,30 @@
"integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==",
"dev": true
},
"node_modules/node-abi": {
"version": "3.78.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -5898,6 +6137,32 @@
"node": ">=8"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -5960,6 +6225,16 @@
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/pure-rand": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz",
@@ -6034,6 +6309,30 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -6417,6 +6716,51 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ -6746,6 +7090,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -6990,6 +7362,18 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/two-product": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/two-product/-/two-product-1.0.2.tgz",

View File

@@ -19,6 +19,7 @@
"@types/xmldom": "^0.1.34",
"@xmldom/xmldom": "^0.9.7",
"axios": "^1.7.8",
"better-sqlite3": "^12.4.1",
"dayjs": "^1.11.13",
"eta": "^2.2.0",
"express": "^4.18.3",
@@ -41,6 +42,7 @@
"xpath": "^0.0.34"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/mocha": "^10.0.10",

View File

@@ -18,7 +18,7 @@ import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service";
import { SystemClock } from "./clock";
import { JWTSmapiLoginTokens } from "./smapi_auth";
import { FileSmapiTokenStore } from "./smapi_token_store";
import { SQLiteSmapiTokenStore } from "./smapi_token_store";
const config = readConfig();
const clock = SystemClock;
@@ -82,6 +82,16 @@ const version = fs.existsSync(GIT_INFO)
? fs.readFileSync(GIT_INFO).toString().trim()
: "v??";
// Initialize SQLite token store
const smapiTokenStore = new SQLiteSmapiTokenStore(config.tokenStore.dbPath);
// Migrate existing JSON tokens if they exist
const legacyJsonPath = "/config/tokens.json";
if (fs.existsSync(legacyJsonPath)) {
logger.info(`Found legacy JSON token file at ${legacyJsonPath}, attempting migration...`);
smapiTokenStore.migrateFromJSON(legacyJsonPath);
}
const app = server(
sonosSystem,
bonob,
@@ -97,7 +107,8 @@ const app = server(
version,
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
externalImageResolver: artistImageFetcher,
smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json")
smapiTokenStore,
tokenCleanupIntervalMinutes: config.tokenStore.cleanupIntervalMinutes
}
);
@@ -126,6 +137,7 @@ process.on('SIGTERM', () => {
expressServer.close(() => {
logger.info('HTTP server closed');
});
smapiTokenStore.close();
process.exit(0);
});

View File

@@ -105,5 +105,9 @@ export default function () {
scrobbleTracks: bnbEnvVar<boolean>("SCROBBLE_TRACKS", { default: true, parser: asBoolean }),
reportNowPlaying:
bnbEnvVar<boolean>("REPORT_NOW_PLAYING", { default: true, parser: asBoolean }),
tokenStore: {
dbPath: bnbEnvVar<string>("TOKEN_DB_PATH", { default: "/config/tokens.db" })!,
cleanupIntervalMinutes: bnbEnvVar<number>("TOKEN_CLEANUP_INTERVAL", { default: 60, parser: asInt })!,
},
};
}

View File

@@ -113,6 +113,7 @@ export type ServerOpts = {
smapiAuthTokens: SmapiAuthTokens;
externalImageResolver: ImageFetcher;
smapiTokenStore: SmapiTokenStore;
tokenCleanupIntervalMinutes: number;
};
const DEFAULT_SERVER_OPTS: ServerOpts = {
@@ -130,6 +131,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
),
externalImageResolver: axiosImageFetcher,
smapiTokenStore: new InMemorySmapiTokenStore(),
tokenCleanupIntervalMinutes: 60,
};
function server(
@@ -747,7 +749,8 @@ function server(
i8n,
serverOpts.smapiAuthTokens,
serverOpts.smapiTokenStore,
serverOpts.logRequests
serverOpts.logRequests,
serverOpts.tokenCleanupIntervalMinutes
);
if (serverOpts.applyContextPath) {

View File

@@ -289,6 +289,7 @@ const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
title: playlist.name,
albumArtURI: coverArtURI(bonobUrl, playlist).href(),
canPlay: true,
canEnumerate: true,
attributes: {
readOnly: false,
userContent: false,
@@ -406,7 +407,8 @@ function bindSmapiSoapServiceToExpress(
i8n: I8N,
smapiAuthTokens: SmapiAuthTokens,
tokenStore: SmapiTokenStore,
_logRequests: boolean
_logRequests: boolean,
tokenCleanupIntervalMinutes: number = 60
) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore);
@@ -420,14 +422,16 @@ function bindSmapiSoapServiceToExpress(
logger.error("Failed to cleanup expired tokens on startup", { error });
}
// Clean up expired tokens every hour
// Clean up expired tokens periodically
const cleanupIntervalMs = tokenCleanupIntervalMinutes * 60 * 1000;
logger.info(`Token cleanup will run every ${tokenCleanupIntervalMinutes} minute(s)`);
setInterval(() => {
try {
tokenStore.cleanupExpired(smapiAuthTokens);
} catch (error) {
logger.error("Failed to cleanup expired tokens", { error });
}
}, 60 * 60 * 1000).unref(); // Run every hour, but don't prevent process exit
}, cleanupIntervalMs).unref(); // Don't prevent process exit
const urlWithToken = (accessToken: string) =>
bonobUrl.append({
@@ -802,6 +806,30 @@ function bindSmapiSoapServiceToExpress(
// </getExtendedMetadataResult>
},
}));
case "playlists":
return musicLibrary
.playlists()
.then((it) =>
Promise.all(
it.map((playlist) => ({
id: playlist.id,
name: playlist.name,
coverArt: playlist.coverArt,
entries: [],
}))
)
)
.then(slice2(paging))
.then(([page, total]) => ({
getExtendedMetadataResult: {
count: page.length,
index: paging._index,
total,
mediaCollection: page.map((it) =>
playlist(urlWithToken(apiKey), it)
),
},
}));
default:
throw `Unsupported getExtendedMetadata id=${id}`;
}
@@ -877,7 +905,8 @@ function bindSmapiSoapServiceToExpress(
id: "playlists",
title: lang("playlists"),
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
itemType: "playlist",
itemType: "container",
canEnumerate: true,
attributes: {
readOnly: false,
userContent: true,

View File

@@ -4,6 +4,8 @@ import logger from "./logger";
import { SmapiToken, SmapiAuthTokens } from "./smapi_auth";
import { either as E } from "fp-ts";
export { SQLiteSmapiTokenStore } from "./sqlite_smapi_token_store";
export interface SmapiTokenStore {
get(token: string): SmapiToken | undefined;
set(token: string, fullSmapiToken: SmapiToken): void;

View File

@@ -0,0 +1,200 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import logger from "./logger";
import { SmapiToken, SmapiAuthTokens } from "./smapi_auth";
import { either as E } from "fp-ts";
import { SmapiTokenStore } from "./smapi_token_store";
export class SQLiteSmapiTokenStore implements SmapiTokenStore {
private db!: Database.Database;
private readonly dbPath: string;
constructor(dbPath: string) {
this.dbPath = dbPath;
this.initializeDatabase();
}
private initializeDatabase(): void {
try {
// Ensure the directory exists
const dir = path.dirname(this.dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
logger.info(`Created token storage directory: ${dir}`);
}
// Open database connection
this.db = new Database(this.dbPath);
// Create table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS smapi_tokens (
token_key TEXT PRIMARY KEY,
token TEXT NOT NULL,
key TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
`);
// Create index for faster lookups
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_created_at ON smapi_tokens(created_at)
`);
const count = this.db.prepare("SELECT COUNT(*) as count FROM smapi_tokens").get() as { count: number };
logger.info(`SQLite token store initialized at ${this.dbPath} with ${count.count} token(s)`);
} catch (error) {
logger.error(`Failed to initialize SQLite token store at ${this.dbPath}`, { error });
throw error;
}
}
get(tokenKey: string): SmapiToken | undefined {
try {
const stmt = this.db.prepare("SELECT token, key FROM smapi_tokens WHERE token_key = ?");
const row = stmt.get(tokenKey) as { token: string; key: string } | undefined;
if (!row) {
return undefined;
}
return {
token: row.token,
key: row.key,
};
} catch (error) {
logger.error(`Failed to get token from SQLite store`, { error });
return undefined;
}
}
set(tokenKey: string, fullSmapiToken: SmapiToken): void {
try {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO smapi_tokens (token_key, token, key)
VALUES (?, ?, ?)
`);
stmt.run(tokenKey, fullSmapiToken.token, fullSmapiToken.key);
logger.debug(`Saved token to SQLite store`);
} catch (error) {
logger.error(`Failed to save token to SQLite store`, { error });
}
}
delete(tokenKey: string): void {
try {
const stmt = this.db.prepare("DELETE FROM smapi_tokens WHERE token_key = ?");
stmt.run(tokenKey);
logger.debug(`Deleted token from SQLite store`);
} catch (error) {
logger.error(`Failed to delete token from SQLite store`, { error });
}
}
getAll(): { [tokenKey: string]: SmapiToken } {
try {
const stmt = this.db.prepare("SELECT token_key, token, key FROM smapi_tokens");
const rows = stmt.all() as Array<{ token_key: string; token: string; key: string }>;
const tokens: { [tokenKey: string]: SmapiToken } = {};
for (const row of rows) {
tokens[row.token_key] = {
token: row.token,
key: row.key,
};
}
return tokens;
} catch (error) {
logger.error(`Failed to get all tokens from SQLite store`, { error });
return {};
}
}
cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number {
try {
const tokens = this.getAll();
const tokenKeys = Object.keys(tokens);
let deletedCount = 0;
for (const tokenKey of tokenKeys) {
const smapiToken = tokens[tokenKey];
if (smapiToken) {
const verifyResult = smapiAuthTokens.verify(smapiToken);
// Only delete if token verification fails with InvalidTokenError
// Do NOT delete ExpiredTokenError as those can still be refreshed
if (E.isLeft(verifyResult)) {
const error = verifyResult.left;
// Only delete invalid tokens, not expired ones (which can be refreshed)
if (error._tag === 'InvalidTokenError') {
logger.debug(`Deleting invalid token from SQLite store`);
this.delete(tokenKey);
deletedCount++;
}
}
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} invalid token(s) from SQLite store`);
}
return deletedCount;
} catch (error) {
logger.error(`Failed to cleanup expired tokens from SQLite store`, { error });
return 0;
}
}
/**
* Migrate tokens from a JSON file to the SQLite database
* @param jsonFilePath Path to the JSON file containing tokens
* @returns Number of tokens migrated
*/
migrateFromJSON(jsonFilePath: string): number {
try {
if (!fs.existsSync(jsonFilePath)) {
logger.info(`No JSON token file found at ${jsonFilePath}, skipping migration`);
return 0;
}
const data = fs.readFileSync(jsonFilePath, "utf8");
const tokens: { [tokenKey: string]: SmapiToken } = JSON.parse(data);
const tokenKeys = Object.keys(tokens);
let migratedCount = 0;
for (const tokenKey of tokenKeys) {
const token = tokens[tokenKey];
if (token) {
this.set(tokenKey, token);
migratedCount++;
}
}
logger.info(`Migrated ${migratedCount} token(s) from ${jsonFilePath} to SQLite`);
// Optionally rename the old JSON file to .bak
const backupPath = `${jsonFilePath}.bak`;
fs.renameSync(jsonFilePath, backupPath);
logger.info(`Backed up original JSON file to ${backupPath}`);
return migratedCount;
} catch (error) {
logger.error(`Failed to migrate tokens from JSON file ${jsonFilePath}`, { error });
return 0;
}
}
/**
* Close the database connection
*/
close(): void {
try {
this.db.close();
logger.info("SQLite token store connection closed");
} catch (error) {
logger.error("Failed to close SQLite token store connection", { error });
}
}
}

View File

@@ -94,7 +94,7 @@ class SonosDriver {
.get(this.bonobUrl.append({ pathname: "/" }).pathname())
.expect(200)
.then((response) => {
const m = response.text.match(/ action="(.*)" /i);
const m = response.text.match(/ action="([^"]+)"/i);
return m![1]!;
});

View File

@@ -240,7 +240,7 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(0\)</h2>`);
expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(0\\)`));
expect(res.text).not.toMatch(/class=device/);
expect(res.text).toContain(lang("noSonosDevices"));
});
@@ -276,7 +276,7 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(0\)</h2>`);
expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(0\\)`));
expect(res.text).not.toMatch(/class=device/);
expect(res.text).toContain(lang("noSonosDevices"));
});
@@ -290,7 +290,7 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("services")} \(0\)</h2>`);
expect(res.text).toMatch(new RegExp(`${lang("services")}.*\\(0\\)`));
});
});
});
@@ -352,9 +352,9 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(2\)</h2>`);
expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/);
expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/);
expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(2\\)`));
expect(res.text).toMatch(/device1.*172\.0\.0\.1:4301/);
expect(res.text).toMatch(/device2.*172\.0\.0\.2:4302/);
});
});
@@ -366,11 +366,11 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("services")} \(4\)</h2>`);
expect(res.text).toMatch(/s1\s+\(1\)/);
expect(res.text).toMatch(/s2\s+\(2\)/);
expect(res.text).toMatch(/s3\s+\(3\)/);
expect(res.text).toMatch(/s4\s+\(4\)/);
expect(res.text).toMatch(new RegExp(`${lang("services")}.*\\(4\\)`));
expect(res.text).toMatch(/s1.*SID:\s*1/);
expect(res.text).toMatch(/s2.*SID:\s*2/);
expect(res.text).toMatch(/s3.*SID:\s*3/);
expect(res.text).toMatch(/s4.*SID:\s*4/);
});
});
@@ -382,14 +382,11 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
`<input type="submit" value="${lang("register")}">`
);
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
expect(res.text).toMatch(
`<h3>${lang("noExistingServiceRegistration")}</h3>`
`<input type="submit" value="${lang("register")}" id="submit">`
);
expect(res.text).toContain(lang("noExistingServiceRegistration"));
expect(res.text).not.toMatch(
`<input type="submit" value="${lang("removeRegistration")}">`
`value="${lang("removeRegistration")}"`
);
});
});
@@ -440,14 +437,11 @@ describe("server", () => {
.send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
`<input type="submit" value="${lang("register")}">`
`<input type="submit" value="${lang("register")}" id="submit">`
);
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
expect(res.text).toContain(lang("existingServiceConfig"));
expect(res.text).toMatch(
`<h3>${lang("existingServiceConfig")}</h3>`
);
expect(res.text).toMatch(
`<input type="submit" value="${lang("removeRegistration")}">`
`<input type="submit" value="${lang("removeRegistration")}" id="submit"`
);
});
});
@@ -632,13 +626,13 @@ describe("server", () => {
expect(res.status).toEqual(200);
expect(res.text).toMatch(`<title>${lang("login")}</title>`);
expect(res.text).toMatch(
`<h1 class="login one-word-per-line">${lang("logInToBonob")}</h1>`
`<h1>${lang("logInToBonob")}</h1>`
);
expect(res.text).toMatch(
`<label for="username">${lang("username")}:</label>`
`<label for="username">${lang("username")}</label>`
);
expect(res.text).toMatch(
`<label for="password">${lang("password")}:</label>`
`<label for="password">${lang("password")}</label>`
);
expect(res.text).toMatch(
`<input type="submit" value="${lang("login")}" id="submit">`

View File

@@ -1160,7 +1160,8 @@ describe("wsdl api", () => {
id: "playlists",
title: "Playlists",
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
itemType: "playlist",
itemType: "container",
canEnumerate: true,
attributes: {
readOnly: "false",
renameable: "false",
@@ -1260,7 +1261,8 @@ describe("wsdl api", () => {
id: "playlists",
title: "Afspeellijsten",
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
itemType: "playlist",
itemType: "container",
canEnumerate: true,
attributes: {
readOnly: "false",
renameable: "false",
@@ -1497,6 +1499,7 @@ describe("wsdl api", () => {
playlist
).href(),
canPlay: true,
canEnumerate: true,
attributes: {
readOnly: "false",
userContent: "false",
@@ -1529,6 +1532,7 @@ describe("wsdl api", () => {
playlist
).href(),
canPlay: true,
canEnumerate: true,
attributes: {
readOnly: "false",
userContent: "false",

View File

@@ -0,0 +1,234 @@
import fs from "fs";
import path from "path";
import { SQLiteSmapiTokenStore } from "../src/sqlite_smapi_token_store";
import { SmapiToken } from "../src/smapi_auth";
import { JWTSmapiLoginTokens } from "../src/smapi_auth";
import { SystemClock } from "../src/clock";
describe("SQLiteSmapiTokenStore", () => {
const testDbPath = path.join(__dirname, "test-tokens.db");
const testJsonPath = path.join(__dirname, "test-tokens.json");
let tokenStore: SQLiteSmapiTokenStore;
beforeEach(() => {
// Clean up any existing test files
if (fs.existsSync(testDbPath)) {
fs.unlinkSync(testDbPath);
}
if (fs.existsSync(testJsonPath)) {
fs.unlinkSync(testJsonPath);
}
if (fs.existsSync(`${testJsonPath}.bak`)) {
fs.unlinkSync(`${testJsonPath}.bak`);
}
tokenStore = new SQLiteSmapiTokenStore(testDbPath);
});
afterEach(() => {
tokenStore.close();
// Clean up test files
if (fs.existsSync(testDbPath)) {
fs.unlinkSync(testDbPath);
}
if (fs.existsSync(testJsonPath)) {
fs.unlinkSync(testJsonPath);
}
if (fs.existsSync(`${testJsonPath}.bak`)) {
fs.unlinkSync(`${testJsonPath}.bak`);
}
});
describe("Database Initialization", () => {
it("should create database file on initialization", () => {
expect(fs.existsSync(testDbPath)).toBe(true);
});
it("should create parent directory if it doesn't exist", () => {
const nestedPath = path.join(__dirname, "nested", "dir", "tokens.db");
const nestedStore = new SQLiteSmapiTokenStore(nestedPath);
expect(fs.existsSync(nestedPath)).toBe(true);
nestedStore.close();
fs.unlinkSync(nestedPath);
fs.rmdirSync(path.dirname(nestedPath));
fs.rmdirSync(path.dirname(path.dirname(nestedPath)));
});
});
describe("Token Operations", () => {
const testToken: SmapiToken = {
token: "test-jwt-token",
key: "test-key-123",
};
it("should set and get a token", () => {
tokenStore.set("token1", testToken);
const retrieved = tokenStore.get("token1");
expect(retrieved).toEqual(testToken);
});
it("should return undefined for non-existent token", () => {
const retrieved = tokenStore.get("non-existent");
expect(retrieved).toBeUndefined();
});
it("should update existing token", () => {
tokenStore.set("token1", testToken);
const updatedToken: SmapiToken = {
token: "updated-jwt-token",
key: "updated-key-456",
};
tokenStore.set("token1", updatedToken);
const retrieved = tokenStore.get("token1");
expect(retrieved).toEqual(updatedToken);
});
it("should delete a token", () => {
tokenStore.set("token1", testToken);
tokenStore.delete("token1");
const retrieved = tokenStore.get("token1");
expect(retrieved).toBeUndefined();
});
it("should get all tokens", () => {
const token1: SmapiToken = { token: "jwt1", key: "key1" };
const token2: SmapiToken = { token: "jwt2", key: "key2" };
const token3: SmapiToken = { token: "jwt3", key: "key3" };
tokenStore.set("tokenKey1", token1);
tokenStore.set("tokenKey2", token2);
tokenStore.set("tokenKey3", token3);
const allTokens = tokenStore.getAll();
expect(Object.keys(allTokens).length).toBe(3);
expect(allTokens["tokenKey1"]).toEqual(token1);
expect(allTokens["tokenKey2"]).toEqual(token2);
expect(allTokens["tokenKey3"]).toEqual(token3);
});
it("should return empty object when no tokens exist", () => {
const allTokens = tokenStore.getAll();
expect(allTokens).toEqual({});
});
});
describe("Token Cleanup", () => {
it("should cleanup invalid tokens", () => {
const smapiAuthTokens = new JWTSmapiLoginTokens(SystemClock, "test-secret", "1h");
// Create valid tokens
const validToken1 = smapiAuthTokens.issue("service-token-1");
const validToken2 = smapiAuthTokens.issue("service-token-2");
// Create invalid token (wrong secret)
const invalidAuthTokens = new JWTSmapiLoginTokens(SystemClock, "different-secret", "1h");
const invalidToken = invalidAuthTokens.issue("service-token-3");
tokenStore.set("valid1", validToken1);
tokenStore.set("valid2", validToken2);
tokenStore.set("invalid", invalidToken);
// Clean up
const deletedCount = tokenStore.cleanupExpired(smapiAuthTokens);
expect(deletedCount).toBe(1);
expect(tokenStore.get("valid1")).toBeDefined();
expect(tokenStore.get("valid2")).toBeDefined();
expect(tokenStore.get("invalid")).toBeUndefined();
});
it("should not cleanup expired tokens that can be refreshed", () => {
// Note: This test would require mocking time to create an expired token
// For now, we just verify the function runs without error
const smapiAuthTokens = new JWTSmapiLoginTokens(SystemClock, "test-secret", "1h");
const validToken = smapiAuthTokens.issue("service-token-1");
tokenStore.set("token1", validToken);
const deletedCount = tokenStore.cleanupExpired(smapiAuthTokens);
expect(deletedCount).toBe(0);
expect(tokenStore.get("token1")).toBeDefined();
});
});
describe("JSON Migration", () => {
it("should migrate tokens from JSON file", () => {
const jsonTokens = {
token1: { token: "jwt1", key: "key1" },
token2: { token: "jwt2", key: "key2" },
token3: { token: "jwt3", key: "key3" },
};
fs.writeFileSync(testJsonPath, JSON.stringify(jsonTokens, null, 2), "utf8");
const migratedCount = tokenStore.migrateFromJSON(testJsonPath);
expect(migratedCount).toBe(3);
expect(tokenStore.get("token1")).toEqual(jsonTokens.token1);
expect(tokenStore.get("token2")).toEqual(jsonTokens.token2);
expect(tokenStore.get("token3")).toEqual(jsonTokens.token3);
});
it("should create backup of original JSON file", () => {
const jsonTokens = {
token1: { token: "jwt1", key: "key1" },
};
fs.writeFileSync(testJsonPath, JSON.stringify(jsonTokens, null, 2), "utf8");
tokenStore.migrateFromJSON(testJsonPath);
expect(fs.existsSync(`${testJsonPath}.bak`)).toBe(true);
expect(fs.existsSync(testJsonPath)).toBe(false);
});
it("should return 0 when JSON file does not exist", () => {
const migratedCount = tokenStore.migrateFromJSON(testJsonPath);
expect(migratedCount).toBe(0);
});
it("should handle empty JSON file", () => {
fs.writeFileSync(testJsonPath, JSON.stringify({}), "utf8");
const migratedCount = tokenStore.migrateFromJSON(testJsonPath);
expect(migratedCount).toBe(0);
});
});
describe("Persistence", () => {
it("should persist tokens across instances", () => {
const testToken: SmapiToken = { token: "jwt1", key: "key1" };
tokenStore.set("token1", testToken);
tokenStore.close();
// Create new instance with same database
const newStore = new SQLiteSmapiTokenStore(testDbPath);
const retrieved = newStore.get("token1");
expect(retrieved).toEqual(testToken);
newStore.close();
});
});
describe("Close", () => {
it("should close database connection without error", () => {
expect(() => tokenStore.close()).not.toThrow();
});
it("should handle multiple close calls gracefully", () => {
tokenStore.close();
// Second close should not throw
expect(() => tokenStore.close()).not.toThrow();
});
});
});

View File

@@ -1,6 +1,12 @@
<% layout('./layout', { title: it.lang("failure") }) %>
<div id="content">
<h1 class="failure"><%= it.message %></h1>
<h1 class="cause"><%= it.cause || "" %></h1>
<div class="message-container">
<div class="logo">✗</div>
<h1><%= it.message %></h1>
<% if (it.cause) { %>
<p style="color: #dc3545; margin-top: 15px;"><%= it.cause %></p>
<% } %>
<p style="color: #dc3545; font-weight: 600; margin-top: 10px;"><%= it.lang("failure") %></p>
</div>
</div>

View File

@@ -1,45 +1,61 @@
<% layout('./layout') %>
<div id="content">
<div width="100%" style="text-align:right;color:grey"><%= it.version %></div>
<h1><%= it.bonobService.name %> (<%= it.bonobService.sid %>)</h1>
<h3><%= it.lang("expectedConfig") %></h3>
<div><%= JSON.stringify(it.bonobService) %></div>
<br/>
<div id="content" class="index-content">
<div style="text-align:right;color:#999;font-size:0.85rem;margin-bottom:20px"><%= it.version %></div>
<div class="logo">🎵</div>
<h1><%= it.bonobService.name %></h1>
<p style="color:#999;margin-bottom:30px">Service ID: <%= it.bonobService.sid %></p>
<% if(it.devices.length > 0) { %>
<form action="<%= it.createRegistrationRoute %>" method="POST">
<input type="submit" value="<%= it.lang("register") %>">
<form action="<%= it.createRegistrationRoute %>" method="POST" style="margin-bottom:30px">
<input type="submit" value="<%= it.lang("register") %>" id="submit">
</form>
<br/>
<% } else { %>
<h3><%= it.lang("noSonosDevices") %></h3>
<br/>
<p style="color:#dc3545;font-weight:600;margin:30px 0"><%= it.lang("noSonosDevices") %></p>
<% } %>
<% if(it.registeredBonobService) { %>
<h3><%= it.lang("existingServiceConfig") %></h3>
<div><%= JSON.stringify(it.registeredBonobService) %></div>
<% } else { %>
<h3><%= it.lang("noExistingServiceRegistration") %></h3>
<% } %>
<% if(it.registeredBonobService) { %>
<br/>
<form action="<%= it.removeRegistrationRoute %>" method="POST">
<input type="submit" value="<%= it.lang("removeRegistration") %>">
</form>
<% } %>
<br/>
<h2><%= it.lang("devices") %> (<%= it.devices.length %>)</h2>
<ul>
<% it.devices.forEach(function(d){ %>
<li><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li>
<% }) %>
</ul>
<h2><%= it.lang("services") %> (<%= it.services.length %>)</h2>
<ul>
<% it.services.forEach(function(s){ %>
<li><%= s.name %> (<%= s.sid %>)</li>
<% }) %>
</ul>
<div style="margin:20px 0;padding:15px;background:#f8f9fa;border-radius:8px">
<h3 style="font-size:1.1rem;margin-bottom:10px;color:#667eea"><%= it.lang("existingServiceConfig") %></h3>
<pre style="font-size:0.85rem;text-align:left;overflow-x:auto"><%= JSON.stringify(it.registeredBonobService, null, 2) %></pre>
</div>
<% } else { %>
<p style="color:#999;margin:20px 0"><%= it.lang("noExistingServiceRegistration") %></p>
<% } %>
<% if(it.registeredBonobService) { %>
<form action="<%= it.removeRegistrationRoute %>" method="POST" style="margin:20px 0">
<input type="submit" value="<%= it.lang("removeRegistration") %>" id="submit" style="background:#dc3545">
</form>
<% } %>
<div style="margin-top:40px;padding-top:30px;border-top:2px solid #e0e0e0">
<h2 style="font-size:1.5rem;margin-bottom:15px"><%= it.lang("devices") %> (<%= it.devices.length %>)</h2>
<% if(it.devices.length > 0) { %>
<ul style="list-style:none;text-align:left;padding:0">
<% it.devices.forEach(function(d){ %>
<li style="padding:10px;margin:5px 0;background:#f8f9fa;border-radius:6px">
<strong><%= d.name %></strong> <span style="color:#999">(<%= d.ip %>:<%= d.port %>)</span>
</li>
<% }) %>
</ul>
<% } else { %>
<p style="color:#999">No devices found</p>
<% } %>
</div>
<div style="margin-top:30px">
<h2 style="font-size:1.5rem;margin-bottom:15px"><%= it.lang("services") %> (<%= it.services.length %>)</h2>
<% if(it.services.length > 0) { %>
<ul style="list-style:none;text-align:left;padding:0">
<% it.services.forEach(function(s){ %>
<li style="padding:10px;margin:5px 0;background:#f8f9fa;border-radius:6px">
<strong><%= s.name %></strong> <span style="color:#999">(SID: <%= s.sid %>)</span>
</li>
<% }) %>
</ul>
<% } else { %>
<p style="color:#999">No services registered</p>
<% } %>
</div>
</div>

View File

@@ -1,46 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= it.title || "bonob" %></title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
div#content {
margin: auto;
width: 60%;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 50px 40px;
max-width: 450px;
width: 100%;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
h1 {
font-size: 700%;
font-size: 2.5rem;
font-weight: 700;
color: #333;
text-align: center;
margin-bottom: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
form {
display: flex;
flex-direction: column;
gap: 25px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 300%;
font-size: 0.95rem;
font-weight: 600;
color: #555;
margin-left: 5px;
}
input {
font-size: 300%;
width: 80%;
input[type="text"],
input[type="password"] {
width: 100%;
padding: 15px 20px;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 12px;
transition: all 0.3s ease;
background: #f8f9fa;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
input#submit {
margin-top: 100px
width: 100%;
padding: 16px;
font-size: 1.1rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.one-word-per-line {
word-spacing: 100000px;
input#submit:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.login{
width: min-intrinsic;
width: -webkit-min-content;
width: -moz-min-content;
width: min-content;
display: table-caption;
display: -ms-grid;
-ms-grid-columns: min-content;
input#submit:active {
transform: translateY(0);
}
.logo {
text-align: center;
margin-bottom: 20px;
font-size: 3rem;
opacity: 0.9;
}
/* Success and failure page styles */
.message-container {
text-align: center;
padding: 20px 0;
}
.message-container h1 {
font-size: 2rem;
margin-bottom: 20px;
}
.message-container p {
font-size: 1.1rem;
color: #666;
line-height: 1.6;
}
/* Index page styles */
.index-content {
text-align: center;
}
.index-content h1 {
font-size: 2.5rem;
margin-bottom: 20px;
}
.index-content p {
font-size: 1.1rem;
color: #666;
line-height: 1.8;
margin-bottom: 15px;
}
.index-content a {
color: #667eea;
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.index-content a:hover {
color: #764ba2;
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 500px) {
div#content {
padding: 40px 30px;
}
h1 {
font-size: 2rem;
}
input[type="text"],
input[type="password"] {
padding: 12px 16px;
}
}
</style>
</head>

View File

@@ -1,12 +1,17 @@
<% layout('./layout', { title: it.lang("login") }) %>
<div id="content">
<h1 class="login one-word-per-line"><%= it.lang("logInToBonob") %></h1>
<div class="logo">🎵</div>
<h1><%= it.lang("logInToBonob") %></h1>
<form action="<%= it.loginRoute %>" method="POST">
<label for="username"><%= it.lang("username") %>:</label><br>
<input type="text" id="username" name="username"><br><br>
<label for="password"><%= it.lang("password") %>:</label><br>
<input type="password" id="password" name="password"><br>
<div class="form-group">
<label for="username"><%= it.lang("username") %></label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password"><%= it.lang("password") %></label>
<input type="password" id="password" name="password" required>
</div>
<input type="hidden" name="linkCode" value="<%= it.linkCode %>">
<input type="submit" value="<%= it.lang("login") %>" id="submit">
</form>

View File

@@ -1,5 +1,9 @@
<% layout('./layout', { title: it.lang("success") }) %>
<div id="content">
<h1 class="success"><%= it.message %></h1>
<div class="message-container">
<div class="logo">✓</div>
<h1><%= it.message %></h1>
<p style="color: #28a745; font-weight: 600; margin-top: 10px;"><%= it.lang("success") %></p>
</div>
</div>