Add extensible notification providers + UI/API

Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
kikootwo
2026-02-10 15:06:20 -05:00
parent 4a38dd3da8
commit af0eaceb98
73 changed files with 3421 additions and 866 deletions
+10
View File
@@ -406,6 +406,16 @@ export class NZBGetService implements IDownloadClient {
}
}
/** Not applicable for usenet clients */
async getCategories(): Promise<string[]> {
return [];
}
/** Not applicable for usenet clients */
async setCategory(_id: string, _category: string): Promise<void> {
// No-op: post-import category is scoped to torrent clients
}
// =========================================================================
// Category Management
// =========================================================================
+49
View File
@@ -208,6 +208,55 @@ export class ProwlarrService {
}
}
/**
* Search with multiple query variations to increase coverage
* Fires 2 queries per call: "title author" and "title", then deduplicates by guid
*/
async searchWithVariations(
title: string,
author: string,
filters?: SearchFilters
): Promise<TorrentResult[]> {
const queries = [
`${title} ${author}`,
title,
];
logger.info(`Searching with ${queries.length} query variations`, { queries });
const allResults: TorrentResult[] = [];
for (const query of queries) {
try {
const results = await this.search(query, filters);
logger.info(`Query "${query}" returned ${results.length} results`);
allResults.push(...results);
} catch (error) {
logger.error(`Query "${query}" failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other queries even if one fails
}
}
const deduplicated = this.deduplicateResults(allResults);
logger.info(`Multi-query search: ${allResults.length} total → ${deduplicated.length} after dedup (${allResults.length - deduplicated.length} duplicates removed)`);
return deduplicated;
}
/**
* Deduplicate results by guid, preserving order (first occurrence wins)
*/
private deduplicateResults(results: TorrentResult[]): TorrentResult[] {
const seen = new Set<string>();
return results.filter(result => {
if (seen.has(result.guid)) {
return false;
}
seen.add(result.guid);
return true;
});
}
/**
* Get list of configured indexers
*/
@@ -729,6 +729,26 @@ export class QBittorrentService implements IDownloadClient {
}
}
/**
* Get all configured categories from qBittorrent
*/
async getCategories(): Promise<string[]> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
});
return Object.keys(response.data || {});
} catch (error) {
logger.error('Failed to get categories', { error: error instanceof Error ? error.message : String(error) });
return [];
}
}
/**
* Set category for torrent
*/
+10
View File
@@ -825,6 +825,16 @@ export class SABnzbdService implements IDownloadClient {
await this.archiveCompletedNZB(id);
}
/** Not applicable for usenet clients */
async getCategories(): Promise<string[]> {
return [];
}
/** Not applicable for usenet clients */
async setCategory(_id: string, _category: string): Promise<void> {
// No-op: post-import category is scoped to torrent clients
}
/**
* Map NZBInfo to the unified DownloadInfo format.
*/
@@ -441,6 +441,29 @@ export class TransmissionService implements IDownloadClient {
// No-op: torrents are managed by the seeding cleanup scheduler
}
/**
* Get available categories/labels.
* Transmission uses free-form labels — no predefined list to fetch.
*/
async getCategories(): Promise<string[]> {
return [];
}
/**
* Set the label for a torrent.
* Uses the torrent-set RPC method to replace the labels array.
*/
async setCategory(id: string, category: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-set', { ids: [torrent.hashString], labels: [category] });
logger.info(`Set label for torrent ${id}: ${category}`);
} catch (error) {
logger.error('Failed to set label', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to set torrent label');
}
}
// =========================================================================
// Internal Helpers
// =========================================================================
@@ -177,4 +177,22 @@ export interface IDownloadClient {
* @param id - Download ID
*/
postProcess(id: string): Promise<void>;
/**
* Get available categories/labels from the download client.
* - qBittorrent: Returns configured category names
* - Transmission: Returns empty array (uses free-form labels)
* - Usenet clients: Returns empty array (feature scoped to torrent clients)
*/
getCategories(): Promise<string[]>;
/**
* Set the category/label for a download.
* - qBittorrent: Sets torrent category
* - Transmission: Sets torrent label
* - Usenet clients: No-op
* @param id - Download ID
* @param category - Category/label name to assign
*/
setCategory(id: string, category: string): Promise<void>;
}
@@ -180,6 +180,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
},
});
// Apply post-import category to torrent client if configured
await applyPostImportCategory(requestId, logger);
logger.info(`Request ${requestId} completed successfully - status: downloaded`, {
success: true,
message: 'Files organized successfully',
@@ -606,6 +609,9 @@ async function processEbookOrganization(
},
});
// Apply post-import category to torrent client if configured
await applyPostImportCategory(requestId, logger);
logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
// Send "available" notification for ebooks at downloaded state
@@ -753,6 +759,59 @@ async function createEbookRequestIfEnabled(
}
}
// =========================================================================
// POST-IMPORT CATEGORY
// =========================================================================
/**
* Apply post-import category to the download client after successful import.
* Only applies to torrent clients (qBittorrent/Transmission) when configured.
* Non-fatal: logs a warning on failure but does not fail the job.
*/
async function applyPostImportCategory(
requestId: string,
logger: RMABLogger
): Promise<void> {
try {
// Get download history to find client type and download ID
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
if (!downloadHistory?.downloadClientId || !downloadHistory?.downloadClient) {
return;
}
const clientType = downloadHistory.downloadClient as DownloadClientType;
// Only applies to torrent clients
const protocol = CLIENT_PROTOCOL_MAP[clientType];
if (protocol !== 'torrent') {
return;
}
// Get client config and check if postImportCategory is set
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const clients = await manager.getAllClients();
const clientConfig = clients.find(c => c.enabled && c.type === clientType);
if (!clientConfig?.postImportCategory) {
return;
}
logger.info(`Applying post-import category "${clientConfig.postImportCategory}" to download ${downloadHistory.downloadClientId}`);
const service = await manager.createClientFromConfig(clientConfig);
await service.setCategory(downloadHistory.downloadClientId, clientConfig.postImportCategory);
logger.info(`Post-import category applied successfully`);
} catch (error) {
logger.warn(`Failed to apply post-import category: ${error instanceof Error ? error.message : String(error)}`);
}
}
// =========================================================================
// DOWNLOAD CLEANUP
// =========================================================================
@@ -75,10 +75,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service
const prowlarr = await getProwlarrService();
// Build search query (title only - cast wide net, let ranking filter)
const searchQuery = audiobook.title;
logger.info(`Searching for: "${searchQuery}"`);
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
// Search Prowlarr for each group and combine results
const allResults = [];
@@ -88,7 +85,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.search(searchQuery, {
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder
@@ -6,7 +6,7 @@
* to all enabled backends subscribed to the event.
*/
import { getNotificationService } from '../services/notification.service';
import { getNotificationService } from '../services/notification';
import { RMABLogger } from '../utils/logger';
export interface SendNotificationPayload {
+5 -1
View File
@@ -150,7 +150,11 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username must be at least 3 characters' };
}
if (!password || password.length < 8) {
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
if (!password) {
return { success: false, error: 'Password is required' };
}
if (!allowWeakPassword && password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
@@ -35,6 +35,7 @@ export interface DownloadClientConfig {
localPath?: string;
category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir
postImportCategory?: string; // Category to assign after import (torrent clients only)
}
-380
View File
@@ -1,380 +0,0 @@
/**
* Component: Notification Service
* Documentation: documentation/backend/services/notifications.md
*/
import { getEncryptionService } from './encryption.service';
import { RMABLogger } from '../utils/logger';
import { prisma } from '../db';
const logger = RMABLogger.create('NotificationService');
// Event types
export type NotificationEvent =
| 'request_pending_approval'
| 'request_approved'
| 'request_available'
| 'request_error';
// Backend types
export type NotificationBackendType =
| 'discord'
| 'pushover'
| 'email'
| 'slack'
| 'telegram'
| 'webhook';
// Config interfaces
export interface DiscordConfig {
webhookUrl: string;
username?: string;
avatarUrl?: string;
}
export interface PushoverConfig {
userKey: string;
appToken: string;
device?: string;
priority?: number;
}
export type NotificationConfig = DiscordConfig | PushoverConfig;
// Notification payload
export interface NotificationPayload {
event: NotificationEvent;
requestId: string;
title: string;
author: string;
userName: string;
message?: string; // For error events
timestamp: Date;
}
// Discord embed colors by event type
const DISCORD_COLORS = {
request_pending_approval: 0xfbbf24, // yellow-400
request_approved: 0x22c55e, // green-500
request_available: 0x3b82f6, // blue-500
request_error: 0xef4444, // red-500
};
// Discord embed titles
const DISCORD_TITLES = {
request_pending_approval: '📬 New Request Pending Approval',
request_approved: '✅ Request Approved',
request_available: '🎉 Audiobook Available',
request_error: '❌ Request Error',
};
// Pushover priorities
const PUSHOVER_PRIORITIES = {
request_pending_approval: 0, // Normal
request_approved: 0, // Normal
request_available: 1, // High
request_error: 1, // High
};
export class NotificationService {
private encryptionService = getEncryptionService();
/**
* Send notification to all enabled backends subscribed to the event
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
try {
// Get all enabled backends subscribed to this event
const backends = await prisma.notificationBackend.findMany({
where: {
enabled: true,
events: {
array_contains: payload.event,
},
},
});
if (backends.length === 0) {
logger.debug(`No backends subscribed to event: ${payload.event}`);
return;
}
logger.info(`Sending notification to ${backends.length} backend(s)`, {
event: payload.event,
requestId: payload.requestId,
});
// Send to all backends in parallel (atomic per-backend)
const results = await Promise.allSettled(
backends.map((backend) =>
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
)
);
// Log results
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
event: payload.event,
requestId: payload.requestId,
});
// Log individual failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Failed to send to backend ${backends[index].name}`, {
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
backend: backends[index].type,
});
}
});
} catch (error) {
logger.error('Failed to send notifications', {
error: error instanceof Error ? error.message : String(error),
event: payload.event,
requestId: payload.requestId,
});
// Don't throw - non-blocking
}
}
/**
* Route notification to type-specific sender
*/
private async sendToBackend(
type: NotificationBackendType,
config: any,
payload: NotificationPayload
): Promise<void> {
// Decrypt config
const decryptedConfig = this.decryptConfig(config);
switch (type) {
case 'discord':
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
case 'pushover':
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
default:
throw new Error(`Unsupported backend type: ${type}`);
}
}
/**
* Send Discord webhook notification
*/
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
const embed = this.formatDiscordEmbed(payload);
const body = {
username: config.username || 'ReadMeABook',
avatar_url: config.avatarUrl,
embeds: [embed],
};
const response = await fetch(config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
}
}
/**
* Send Pushover notification
*/
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
const { title, message } = this.formatPushoverMessage(payload);
const body = new URLSearchParams({
token: config.appToken,
user: config.userKey,
title,
message,
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
...(config.device && { device: config.device }),
});
const response = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
}
const result = await response.json();
if (result.status !== 1) {
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
}
}
/**
* Format Discord rich embed
*/
private formatDiscordEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const fields = [
{ name: 'Title', value: title, inline: false },
{ name: 'Author', value: author, inline: true },
{ name: 'Requested By', value: userName, inline: true },
];
if (message) {
fields.push({ name: 'Error', value: message, inline: false });
}
return {
title: DISCORD_TITLES[event],
color: DISCORD_COLORS[event],
fields,
footer: {
text: `Request ID: ${requestId}`,
},
timestamp: timestamp.toISOString(),
};
}
/**
* Format Pushover message
*/
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
let eventTitle = '';
let eventEmoji = '';
switch (event) {
case 'request_pending_approval':
eventTitle = 'New Request Pending Approval';
eventEmoji = '📬';
break;
case 'request_approved':
eventTitle = 'Request Approved';
eventEmoji = '✅';
break;
case 'request_available':
eventTitle = 'Audiobook Available';
eventEmoji = '🎉';
break;
case 'request_error':
eventTitle = 'Request Error';
eventEmoji = '❌';
break;
}
const messageLines = [
`${eventEmoji} ${eventTitle}`,
'',
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
];
if (message) {
messageLines.push('', `⚠️ Error: ${message}`);
}
return {
title: eventTitle,
message: messageLines.join('\n'),
};
}
/**
* Decrypt sensitive config values
*/
private decryptConfig(config: any): any {
const decrypted = { ...config };
// Discord: decrypt webhookUrl
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
}
// Pushover: decrypt userKey and appToken
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
}
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
}
return decrypted;
}
/**
* Check if a value is encrypted (has iv:authTag:data format)
*/
private isEncrypted(value: string): boolean {
return value.includes(':') && value.split(':').length === 3;
}
/**
* Encrypt sensitive config values before saving
*/
encryptConfig(type: NotificationBackendType, config: any): any {
const encrypted = { ...config };
switch (type) {
case 'discord':
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
}
break;
case 'pushover':
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
}
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
}
break;
}
return encrypted;
}
/**
* Mask sensitive config values for API responses
*/
maskConfig(type: NotificationBackendType, config: any): any {
const masked = { ...config };
switch (type) {
case 'discord':
if (masked.webhookUrl) {
masked.webhookUrl = '••••••••';
}
break;
case 'pushover':
if (masked.userKey) {
masked.userKey = '••••••••';
}
if (masked.appToken) {
masked.appToken = '••••••••';
}
break;
}
return masked;
}
}
// Singleton instance
let notificationService: NotificationService | null = null;
export function getNotificationService(): NotificationService {
if (!notificationService) {
notificationService = new NotificationService();
}
return notificationService;
}
@@ -0,0 +1,60 @@
/**
* Notification Provider Interface
* Documentation: documentation/backend/services/notifications.md
*/
// Event types
export type NotificationEvent =
| 'request_pending_approval'
| 'request_approved'
| 'request_available'
| 'request_error';
// Backend type — string-based, registry is the runtime source of truth
export type NotificationBackendType = string;
// Notification payload
export interface NotificationPayload {
event: NotificationEvent;
requestId: string;
title: string;
author: string;
userName: string;
message?: string; // For error events
timestamp: Date;
}
// Provider config field definition for dynamic UI rendering
export interface ProviderConfigField {
name: string;
label: string;
type: 'text' | 'password' | 'select' | 'number';
required: boolean;
placeholder?: string;
defaultValue?: string | number;
options?: { label: string; value: string | number }[];
}
// Provider metadata for self-describing providers
export interface ProviderMetadata {
type: string;
displayName: string;
description: string;
iconLabel: string;
iconColor: string;
configFields: ProviderConfigField[];
}
export interface INotificationProvider {
/** Provider identifier */
type: string;
/** Config field names that need encryption/masking */
sensitiveFields: string[];
/** Self-describing metadata for UI and validation */
metadata: ProviderMetadata;
/** Send notification with already-decrypted config */
send(config: Record<string, any>, payload: NotificationPayload): Promise<void>;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Notification Service - Public API
* Documentation: documentation/backend/services/notifications.md
*/
// Interface + shared types
export type {
INotificationProvider,
NotificationEvent,
NotificationBackendType,
NotificationPayload,
ProviderConfigField,
ProviderMetadata,
} from './INotificationProvider';
// Core service
export {
NotificationService,
getNotificationService,
registerProvider,
getProvider,
getRegisteredProviderTypes,
getAllProviderMetadata,
} from './notification.service';
// Provider types
export type { AppriseConfig } from './providers/apprise.provider';
export type { DiscordConfig } from './providers/discord.provider';
export type { NtfyConfig } from './providers/ntfy.provider';
export type { PushoverConfig } from './providers/pushover.provider';
// Provider classes
export { AppriseProvider } from './providers/apprise.provider';
export { DiscordProvider } from './providers/discord.provider';
export { NtfyProvider } from './providers/ntfy.provider';
export { PushoverProvider } from './providers/pushover.provider';
@@ -0,0 +1,187 @@
/**
* Component: Notification Service
* Documentation: documentation/backend/services/notifications.md
*/
import { getEncryptionService } from '../encryption.service';
import { RMABLogger } from '../../utils/logger';
import { prisma } from '../../db';
import { INotificationProvider, NotificationPayload, ProviderMetadata } from './INotificationProvider';
import { AppriseProvider } from './providers/apprise.provider';
import { DiscordProvider } from './providers/discord.provider';
import { NtfyProvider } from './providers/ntfy.provider';
import { PushoverProvider } from './providers/pushover.provider';
const logger = RMABLogger.create('NotificationService');
// Provider registry
const providers = new Map<string, INotificationProvider>();
export function registerProvider(provider: INotificationProvider): void {
providers.set(provider.type, provider);
}
export function getProvider(type: string): INotificationProvider | undefined {
return providers.get(type);
}
// Register built-in providers
registerProvider(new AppriseProvider());
registerProvider(new DiscordProvider());
registerProvider(new NtfyProvider());
registerProvider(new PushoverProvider());
export function getRegisteredProviderTypes(): string[] {
return Array.from(providers.keys());
}
export function getAllProviderMetadata(): ProviderMetadata[] {
return Array.from(providers.values()).map((p) => p.metadata);
}
export class NotificationService {
private encryptionService = getEncryptionService();
/**
* Send notification to all enabled backends subscribed to the event
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
try {
// Get all enabled backends subscribed to this event
const backends = await prisma.notificationBackend.findMany({
where: {
enabled: true,
events: {
array_contains: payload.event,
},
},
});
if (backends.length === 0) {
logger.debug(`No backends subscribed to event: ${payload.event}`);
return;
}
logger.info(`Sending notification to ${backends.length} backend(s)`, {
event: payload.event,
requestId: payload.requestId,
});
// Send to all backends in parallel (atomic per-backend)
const results = await Promise.allSettled(
backends.map((backend) =>
this.sendToBackend(backend.type, backend.config, payload)
)
);
// Log results
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
event: payload.event,
requestId: payload.requestId,
});
// Log individual failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Failed to send to backend ${backends[index].name}`, {
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
backend: backends[index].type,
});
}
});
} catch (error) {
logger.error('Failed to send notifications', {
error: error instanceof Error ? error.message : String(error),
event: payload.event,
requestId: payload.requestId,
});
// Don't throw - non-blocking
}
}
/**
* Route notification to type-specific provider
*/
async sendToBackend(
type: string,
config: any,
payload: NotificationPayload
): Promise<void> {
const provider = getProvider(type);
if (!provider) {
throw new Error(`Unsupported backend type: ${type}`);
}
const decryptedConfig = this.decryptConfig(provider.sensitiveFields, config);
return provider.send(decryptedConfig, payload);
}
/**
* Encrypt sensitive config values before saving
*/
encryptConfig(type: string, config: any): any {
const provider = getProvider(type);
if (!provider) {
return { ...config };
}
const encrypted = { ...config };
for (const field of provider.sensitiveFields) {
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
}
}
return encrypted;
}
/**
* Mask sensitive config values for API responses
*/
maskConfig(type: string, config: any): any {
const provider = getProvider(type);
if (!provider) {
return { ...config };
}
const masked = { ...config };
for (const field of provider.sensitiveFields) {
if (masked[field]) {
masked[field] = '••••••••';
}
}
return masked;
}
/**
* Decrypt sensitive config values
*/
private decryptConfig(sensitiveFields: string[], config: any): any {
const decrypted = { ...config };
for (const field of sensitiveFields) {
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
}
}
return decrypted;
}
/**
* Check if a value is encrypted (has iv:authTag:data format)
*/
private isEncrypted(value: string): boolean {
return value.includes(':') && value.split(':').length === 3;
}
}
// Singleton instance
let notificationService: NotificationService | null = null;
export function getNotificationService(): NotificationService {
if (!notificationService) {
notificationService = new NotificationService();
}
return notificationService;
}
@@ -0,0 +1,133 @@
/**
* Component: Apprise Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
export interface AppriseConfig {
serverUrl: string;
urls?: string;
configKey?: string;
tag?: string;
authToken?: string;
}
// Apprise notification types by event
const APPRISE_TYPES: Record<string, string> = {
request_pending_approval: 'info',
request_approved: 'success',
request_available: 'success',
request_error: 'failure',
};
export class AppriseProvider implements INotificationProvider {
type = 'apprise' as const;
sensitiveFields = ['urls', 'authToken'];
metadata: ProviderMetadata = {
type: 'apprise',
displayName: 'Apprise',
description: 'Send notifications via Apprise API to 100+ services',
iconLabel: 'A',
iconColor: 'bg-purple-500',
configFields: [
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'http://apprise:8000' },
{ name: 'urls', label: 'Notification URLs', type: 'password', required: false, placeholder: 'slack://token, discord://webhook_id/token, ...' },
{ name: 'configKey', label: 'Config Key', type: 'text', required: false, placeholder: 'Persistent configuration key' },
{ name: 'tag', label: 'Tag', type: 'text', required: false, placeholder: 'Filter tag for stateful config' },
{ name: 'authToken', label: 'Auth Token', type: 'password', required: false, placeholder: 'Optional API auth token' },
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const appriseConfig = config as unknown as AppriseConfig;
const { title, body } = this.formatMessage(payload);
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
const notificationType = APPRISE_TYPES[payload.event] || 'info';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (appriseConfig.authToken) {
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
}
// Stateful mode: use configKey endpoint
if (appriseConfig.configKey) {
const url = `${serverUrl}/notify/${appriseConfig.configKey}`;
const requestBody: Record<string, string> = {
title,
body,
type: notificationType,
};
if (appriseConfig.tag) {
requestBody.tag = appriseConfig.tag;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
}
return;
}
// Stateless mode: send URLs directly
if (!appriseConfig.urls) {
throw new Error('Apprise requires either notification URLs or a config key');
}
const url = `${serverUrl}/notify/`;
const requestBody = {
urls: appriseConfig.urls,
title,
body,
type: notificationType,
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
}
}
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message } = payload;
const eventTitles: Record<string, string> = {
request_pending_approval: 'New Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
const messageLines = [
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
];
if (message) {
messageLines.push(`⚠️ Error: ${message}`);
}
return {
title: eventTitles[event],
body: messageLines.join('\n'),
};
}
}
@@ -0,0 +1,91 @@
/**
* Component: Discord Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
export interface DiscordConfig {
webhookUrl: string;
username?: string;
avatarUrl?: string;
}
// Discord embed colors by event type
const DISCORD_COLORS = {
request_pending_approval: 0xfbbf24, // yellow-400
request_approved: 0x22c55e, // green-500
request_available: 0x3b82f6, // blue-500
request_error: 0xef4444, // red-500
};
// Discord embed titles
const DISCORD_TITLES = {
request_pending_approval: '📬 New Request Pending Approval',
request_approved: '✅ Request Approved',
request_available: '🎉 Audiobook Available',
request_error: '❌ Request Error',
};
export class DiscordProvider implements INotificationProvider {
type = 'discord' as const;
sensitiveFields = ['webhookUrl'];
metadata: ProviderMetadata = {
type: 'discord',
displayName: 'Discord',
description: 'Send notifications via Discord webhook',
iconLabel: 'D',
iconColor: 'bg-indigo-500',
configFields: [
{ name: 'webhookUrl', label: 'Webhook URL', type: 'text', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
{ name: 'username', label: 'Username', type: 'text', required: false, placeholder: 'ReadMeABook', defaultValue: 'ReadMeABook' },
{ name: 'avatarUrl', label: 'Avatar URL', type: 'text', required: false, placeholder: 'https://example.com/avatar.png', defaultValue: '' },
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const discordConfig = config as unknown as DiscordConfig;
const embed = this.formatEmbed(payload);
const body = {
username: discordConfig.username || 'ReadMeABook',
avatar_url: discordConfig.avatarUrl,
embeds: [embed],
};
const response = await fetch(discordConfig.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
}
}
private formatEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const fields = [
{ name: 'Title', value: title, inline: false },
{ name: 'Author', value: author, inline: true },
{ name: 'Requested By', value: userName, inline: true },
];
if (message) {
fields.push({ name: 'Error', value: message, inline: false });
}
return {
title: DISCORD_TITLES[event],
color: DISCORD_COLORS[event],
fields,
footer: {
text: `Request ID: ${requestId}`,
},
timestamp: timestamp.toISOString(),
};
}
}
@@ -0,0 +1,109 @@
/**
* Component: ntfy Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
export interface NtfyConfig {
serverUrl?: string;
topic: string;
accessToken?: string;
priority?: number;
}
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent)
const NTFY_PRIORITIES = {
request_pending_approval: 3, // Default
request_approved: 3, // Default
request_available: 4, // High
request_error: 4, // High
};
// ntfy tags (emojis) by event type
const NTFY_TAGS = {
request_pending_approval: ['mailbox_with_mail'],
request_approved: ['white_check_mark'],
request_available: ['tada'],
request_error: ['x'],
};
export class NtfyProvider implements INotificationProvider {
type = 'ntfy' as const;
sensitiveFields = ['accessToken'];
metadata: ProviderMetadata = {
type: 'ntfy',
displayName: 'ntfy',
description: 'Send notifications via ntfy pub/sub',
iconLabel: 'N',
iconColor: 'bg-teal-500',
configFields: [
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: false, placeholder: 'https://ntfy.sh', defaultValue: 'https://ntfy.sh' },
{ name: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'readmeabook' },
{ name: 'accessToken', label: 'Access Token', type: 'password', required: false, placeholder: 'tk_...' },
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const ntfyConfig = config as unknown as NtfyConfig;
const { title, message } = this.formatMessage(payload);
const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
const url = `${serverUrl}/${ntfyConfig.topic}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (ntfyConfig.accessToken) {
headers['Authorization'] = `Bearer ${ntfyConfig.accessToken}`;
}
const body = {
topic: ntfyConfig.topic,
title,
message,
priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event],
tags: NTFY_TAGS[payload.event],
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`ntfy API failed: ${response.status} ${errorText}`);
}
}
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
const eventTitles = {
request_pending_approval: 'New Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
const messageLines = [
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
];
if (message) {
messageLines.push(`⚠️ Error: ${message}`);
}
return {
title: eventTitles[event],
message: messageLines.join('\n'),
};
}
}
@@ -0,0 +1,121 @@
/**
* Component: Pushover Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
export interface PushoverConfig {
userKey: string;
appToken: string;
device?: string;
priority?: number;
}
// Pushover priorities by event type
const PUSHOVER_PRIORITIES = {
request_pending_approval: 0, // Normal
request_approved: 0, // Normal
request_available: 1, // High
request_error: 1, // High
};
export class PushoverProvider implements INotificationProvider {
type = 'pushover' as const;
sensitiveFields = ['userKey', 'appToken'];
metadata: ProviderMetadata = {
type: 'pushover',
displayName: 'Pushover',
description: 'Send notifications via Pushover API',
iconLabel: 'P',
iconColor: 'bg-blue-500',
configFields: [
{ name: 'userKey', label: 'User Key', type: 'text', required: true, placeholder: 'Your Pushover user key' },
{ name: 'appToken', label: 'App Token', type: 'text', required: true, placeholder: 'Your Pushover app token' },
{ name: 'device', label: 'Device', type: 'text', required: false, placeholder: 'Optional device name' },
{
name: 'priority', label: 'Priority', type: 'select', required: false, defaultValue: 0,
options: [
{ label: 'Lowest', value: -2 },
{ label: 'Low', value: -1 },
{ label: 'Normal', value: 0 },
{ label: 'High', value: 1 },
{ label: 'Emergency', value: 2 },
],
},
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const pushoverConfig = config as unknown as PushoverConfig;
const { title, message } = this.formatMessage(payload);
const body = new URLSearchParams({
token: pushoverConfig.appToken,
user: pushoverConfig.userKey,
title,
message,
priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]),
...(pushoverConfig.device && { device: pushoverConfig.device }),
});
const response = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
}
const result = await response.json();
if (result.status !== 1) {
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
}
}
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
let eventTitle = '';
let eventEmoji = '';
switch (event) {
case 'request_pending_approval':
eventTitle = 'New Request Pending Approval';
eventEmoji = '📬';
break;
case 'request_approved':
eventTitle = 'Request Approved';
eventEmoji = '✅';
break;
case 'request_available':
eventTitle = 'Audiobook Available';
eventEmoji = '🎉';
break;
case 'request_error':
eventTitle = 'Request Error';
eventEmoji = '❌';
break;
}
const messageLines = [
`${eventEmoji} ${eventTitle}`,
'',
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
];
if (message) {
messageLines.push('', `⚠️ Error: ${message}`);
}
return {
title: eventTitle,
message: messageLines.join('\n'),
};
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Component: Stream-based File Copy Utility
* Documentation: documentation/phase3/file-organization.md
*
* Uses read()/write() syscalls via Node.js streams instead of fs.copyFile(),
* which relies on copy_file_range() — a syscall that fails with EPERM on
* certain filesystem configurations (e.g. cross-export NFS4 mounts).
*/
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
/**
* Copy a file using streams.
*
* Equivalent to `fs.copyFile()` but uses standard read/write syscalls
* instead of `copy_file_range()`, ensuring compatibility with NFS, FUSE,
* and other network/virtual filesystems.
*/
export async function copyFile(source: string, destination: string): Promise<void> {
await pipeline(createReadStream(source), createWriteStream(destination));
}
+7 -6
View File
@@ -8,6 +8,7 @@ import path from 'path';
import axios from 'axios';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { RMABLogger } from './logger';
import { copyFile } from './copy-file';
const moduleLogger = RMABLogger.create('FileOrganizer');
import {
@@ -340,8 +341,8 @@ export class FileOrganizer {
// Copy file (do NOT delete original - needed for seeding)
try {
// Copy file using streaming (handles large files >2GB)
await fs.copyFile(sourcePath, targetFilePath);
// Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE)
await copyFile(sourcePath, targetFilePath);
// Set explicit permissions after copy
await fs.chmod(targetFilePath, 0o644);
@@ -378,7 +379,7 @@ export class FileOrganizer {
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
try {
await fs.access(originalSourcePath, fs.constants.R_OK);
await fs.copyFile(originalSourcePath, targetFilePath);
await copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
@@ -413,7 +414,7 @@ export class FileOrganizer {
try {
// Copy cover art (do NOT delete original)
await fs.copyFile(sourcePath, targetCoverPath);
await copyFile(sourcePath, targetCoverPath);
await fs.chmod(targetCoverPath, 0o644);
result.coverArtFile = targetCoverPath;
result.filesMovedCount++;
@@ -608,7 +609,7 @@ export class FileOrganizer {
const cachedPath = path.join('/app/cache/thumbnails', filename);
// Copy from local cache instead of downloading
await fs.copyFile(cachedPath, targetPath);
await copyFile(cachedPath, targetPath);
await fs.chmod(targetPath, 0o644);
moduleLogger.debug(`Copied cover art from cache: ${filename}`);
} else {
@@ -755,7 +756,7 @@ export class FileOrganizer {
}
// Copy ebook file (do NOT delete original - may need for seeding or retry)
await fs.copyFile(sourceFilePath, targetPath);
await copyFile(sourceFilePath, targetPath);
await fs.chmod(targetPath, 0o644);
await logger?.info(`Copied ebook: ${targetFilename}`);
+29 -7
View File
@@ -37,6 +37,14 @@ const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year', 'series'
*/
const INVALID_PATH_CHARS = /[<>:"|?*]/;
/**
* Placeholder characters for escaped braces during substitution.
* Uses Unicode Private Use Area characters that won't appear in metadata
* and won't be affected by path cleanup operations.
*/
const LBRACE_PLACEHOLDER = '\uE000';
const RBRACE_PLACEHOLDER = '\uE001';
/**
* Sanitize a path component by removing invalid characters
* Reuses logic from file-organizer.ts
@@ -87,6 +95,10 @@ export function substituteTemplate(
): string {
let result = template;
// Replace escaped braces with placeholders before any processing,
// so they survive the variable substitution and path cleanup steps
result = result.replace(/\\\{/g, LBRACE_PLACEHOLDER).replace(/\\\}/g, RBRACE_PLACEHOLDER);
// Substitute each variable
for (const key of VALID_VARIABLES) {
const value = variables[key as keyof TemplateVariables];
@@ -120,6 +132,11 @@ export function substituteTemplate(
.filter(part => part.length > 0)
.join('/');
// Resolve escaped brace placeholders as the final step,
// after all variable substitution and path cleanup is complete
result = result.replace(new RegExp(LBRACE_PLACEHOLDER, 'g'), '{');
result = result.replace(new RegExp(RBRACE_PLACEHOLDER, 'g'), '}');
return result;
}
@@ -153,16 +170,20 @@ export function validateTemplate(template: string): ValidationResult {
};
}
// Check for absolute paths
if (template.startsWith('/') || template.startsWith('\\') || /^[a-zA-Z]:/.test(template)) {
// Check for absolute paths (backslash followed by { or } is a brace escape, not a path)
if (template.startsWith('/') || /^\\(?![{}])/.test(template) || /^[a-zA-Z]:/.test(template)) {
return {
valid: false,
error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")'
};
}
// Extract all variables from template
const variableMatches = template.match(/\{[^}]+\}/g);
// Strip escaped braces (\{ and \}) before parsing so they don't interfere
// with variable extraction or character validation
const templateWithoutEscapedBraces = template.replace(/\\[{}]/g, '');
// Extract all variables from the stripped template
const variableMatches = templateWithoutEscapedBraces.match(/\{[^}]+\}/g);
if (variableMatches) {
for (const match of variableMatches) {
@@ -178,7 +199,7 @@ export function validateTemplate(template: string): ValidationResult {
}
// Remove valid variables temporarily to check for invalid characters
let templateWithoutVars = template;
let templateWithoutVars = templateWithoutEscapedBraces;
for (const varName of VALID_VARIABLES) {
templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), '');
}
@@ -192,8 +213,9 @@ export function validateTemplate(template: string): ValidationResult {
};
}
// Check for backslashes (Windows-style paths)
if (templateWithoutVars.includes('\\')) {
// Check for backslashes that are not brace escapes (Windows-style paths)
// We check the original template: any backslash NOT followed by { or } is invalid
if (/\\(?![{}])/.test(template)) {
return {
valid: false,
error: 'Use forward slashes (/) for path separators, not backslashes (\\)'