mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 (\\)'
|
||||
|
||||
Reference in New Issue
Block a user