Files
bonob/src/sqlite_smapi_token_store.ts
2025-10-24 14:56:56 +02:00

201 lines
6.1 KiB
TypeScript

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;
// Delete both invalid and expired tokens to prevent accumulation
if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
logger.debug(`Deleting ${error._tag} 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 });
}
}
}