Replace json config with SQLite database

This commit is contained in:
Wolfgang Kulhanek
2025-10-22 16:51:40 +02:00
parent c122b9ac90
commit f08004a4f1
8 changed files with 929 additions and 2 deletions

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,7 @@ const app = server(
version,
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
externalImageResolver: artistImageFetcher,
smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json")
smapiTokenStore
}
);
@@ -126,6 +136,7 @@ process.on('SIGTERM', () => {
expressServer.close(() => {
logger.info('HTTP server closed');
});
smapiTokenStore.close();
process.exit(0);
});

View File

@@ -105,5 +105,8 @@ 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" })!,
},
};
}

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 });
}
}
}