mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Encrypt Hardcover Api Token and fix failing tests
This commit is contained in:
@@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service';
|
import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service';
|
||||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -150,12 +151,15 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encryptionService = getEncryptionService();
|
||||||
|
const encryptedToken = encryptionService.encrypt(apiToken);
|
||||||
|
|
||||||
const shelf = await prisma.hardcoverShelf.create({
|
const shelf = await prisma.hardcoverShelf.create({
|
||||||
data: {
|
data: {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
name: listName,
|
name: listName,
|
||||||
listId,
|
listId,
|
||||||
apiToken,
|
apiToken: encryptedToken,
|
||||||
bookCount,
|
bookCount,
|
||||||
coverUrls:
|
coverUrls:
|
||||||
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import axios from 'axios';
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||||
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('HardcoverSync');
|
const logger = RMABLogger.create('HardcoverSync');
|
||||||
@@ -330,9 +331,22 @@ async function processShelf(
|
|||||||
`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`,
|
`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const encryptionService = getEncryptionService();
|
||||||
|
let decryptedToken = shelf.apiToken;
|
||||||
|
try {
|
||||||
|
// Check if the token is encrypted (our new storage method format)
|
||||||
|
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||||
|
decryptedToken = encryptionService.decrypt(shelf.apiToken);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
`Failed to decrypt API token for user ${shelf.user.plexUsername}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
||||||
try {
|
try {
|
||||||
fetchedData = await fetchHardcoverList(shelf.apiToken, shelf.listId);
|
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ export class SchedulerService {
|
|||||||
await this.unscheduleJob(job);
|
await this.unscheduleJob(job);
|
||||||
}
|
}
|
||||||
await prisma.scheduledJob.delete({ where: { id: job.id } });
|
await prisma.scheduledJob.delete({ where: { id: job.id } });
|
||||||
logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`);
|
logger.info(
|
||||||
|
`Removed deprecated scheduled job: ${job.name} (${job.type})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to cleanup deprecated scheduled jobs', {
|
logger.error('Failed to cleanup deprecated scheduled jobs', {
|
||||||
@@ -222,11 +224,13 @@ export class SchedulerService {
|
|||||||
job.type,
|
job.type,
|
||||||
{ scheduledJobId: job.id },
|
{ scheduledJobId: job.id },
|
||||||
job.schedule,
|
job.schedule,
|
||||||
`scheduled-${job.id}`
|
`scheduled-${job.id}`,
|
||||||
);
|
);
|
||||||
logger.info(`Job scheduled: ${job.name} (${job.schedule})`);
|
logger.info(`Job scheduled: ${job.name} (${job.schedule})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) });
|
logger.error(`Failed to schedule job ${job.name}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,11 +243,13 @@ export class SchedulerService {
|
|||||||
await this.jobQueue.removeRepeatableJob(
|
await this.jobQueue.removeRepeatableJob(
|
||||||
job.type,
|
job.type,
|
||||||
job.schedule,
|
job.schedule,
|
||||||
`scheduled-${job.id}`
|
`scheduled-${job.id}`,
|
||||||
);
|
);
|
||||||
logger.info(`Job unscheduled: ${job.name}`);
|
logger.info(`Job unscheduled: ${job.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) });
|
logger.error(`Failed to unschedule job ${job.name}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
// Don't throw - job might not exist in Bull yet
|
// Don't throw - job might not exist in Bull yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +301,7 @@ export class SchedulerService {
|
|||||||
*/
|
*/
|
||||||
async updateScheduledJob(
|
async updateScheduledJob(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateScheduledJobDto
|
dto: UpdateScheduledJobDto,
|
||||||
): Promise<ScheduledJob> {
|
): Promise<ScheduledJob> {
|
||||||
if (dto.schedule) {
|
if (dto.schedule) {
|
||||||
this.validateCronExpression(dto.schedule);
|
this.validateCronExpression(dto.schedule);
|
||||||
@@ -439,7 +445,8 @@ export class SchedulerService {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id'];
|
libraryId =
|
||||||
|
job.payload?.libraryId || absConfig['audiobookshelf.library_id'];
|
||||||
} else {
|
} else {
|
||||||
const plexConfig = await configService.getMany([
|
const plexConfig = await configService.getMany([
|
||||||
'plex_url',
|
'plex_url',
|
||||||
@@ -463,15 +470,18 @@ export class SchedulerService {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id;
|
libraryId =
|
||||||
|
job.payload?.libraryId || plexConfig.plex_audiobook_library_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`);
|
logger.info(
|
||||||
|
`Triggering ${backendMode} library scan for library: ${libraryId}`,
|
||||||
|
);
|
||||||
|
|
||||||
return await this.jobQueue.addPlexScanJob(
|
return await this.jobQueue.addPlexScanJob(
|
||||||
libraryId || '',
|
libraryId || '',
|
||||||
job.payload?.partial,
|
job.payload?.partial,
|
||||||
job.payload?.path
|
job.payload?.path,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +502,6 @@ export class SchedulerService {
|
|||||||
return await this.jobQueue.addAudibleRefreshJob(job.id);
|
return await this.jobQueue.addAudibleRefreshJob(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable a scheduled job
|
* Enable a scheduled job
|
||||||
*/
|
*/
|
||||||
@@ -524,10 +533,12 @@ export class SchedulerService {
|
|||||||
await this.triggerJobNow(job.id);
|
await this.triggerJobNow(job.id);
|
||||||
|
|
||||||
// Stagger triggers to avoid connection pool burst on startup
|
// Stagger triggers to avoid connection pool burst on startup
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) });
|
logger.error(`Failed to trigger overdue job "${job.name}"`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,13 +611,22 @@ export class SchedulerService {
|
|||||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||||
const hourNum = parseInt(hour, 10);
|
const hourNum = parseInt(hour, 10);
|
||||||
const minuteNum = parseInt(minute, 10);
|
const minuteNum = parseInt(minute, 10);
|
||||||
if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) {
|
if (
|
||||||
|
!isNaN(hourNum) &&
|
||||||
|
!isNaN(minuteNum) &&
|
||||||
|
hourNum >= 0 &&
|
||||||
|
hourNum <= 23 &&
|
||||||
|
minuteNum >= 0 &&
|
||||||
|
minuteNum <= 59
|
||||||
|
) {
|
||||||
return 24 * 60 * 60 * 1000; // 24 hours
|
return 24 * 60 * 60 * 1000; // 24 hours
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other patterns, return a conservative default (24 hours)
|
// For other patterns, return a conservative default (24 hours)
|
||||||
logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`);
|
logger.warn(
|
||||||
|
`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`,
|
||||||
|
);
|
||||||
return 24 * 60 * 60 * 1000;
|
return 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +676,7 @@ export class SchedulerService {
|
|||||||
* Trigger Reading shelves sync
|
* Trigger Reading shelves sync
|
||||||
*/
|
*/
|
||||||
private async triggerSyncShelves(job: any): Promise<string> {
|
private async triggerSyncShelves(job: any): Promise<string> {
|
||||||
return await this.jobQueue.addSyncShelvesJob(job.id, undefined, 'goodreads');
|
return await this.jobQueue.addSyncShelvesJob(job.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
const res = await axios.post('https://api.hardcover.app/v1/graphql', {
|
|
||||||
query: "{ __schema { types { name } } }"
|
|
||||||
});
|
|
||||||
console.log(res.data);
|
|
||||||
} catch(e) {
|
|
||||||
console.log(e.response ? e.response.data : e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
@@ -21,7 +21,7 @@ const processorsMock = vi.hoisted(() => ({
|
|||||||
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
|
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
|
||||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||||
processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'),
|
processSyncShelves: vi.fn().mockResolvedValue('ok'),
|
||||||
// Ebook processors
|
// Ebook processors
|
||||||
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
||||||
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||||
@@ -116,8 +116,8 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
|
|||||||
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
|
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({
|
vi.mock('@/lib/processors/sync-shelves.processor', () => ({
|
||||||
processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves,
|
processSyncShelves: processorsMock.processSyncShelves,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Ebook processors
|
// Ebook processors
|
||||||
@@ -564,7 +564,7 @@ describe('JobQueueService', () => {
|
|||||||
expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled();
|
expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled();
|
||||||
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
||||||
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
||||||
expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled();
|
expect(processorsMock.processSyncShelves).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns repeatable jobs from the queue', async () => {
|
it('returns repeatable jobs from the queue', async () => {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ const jobQueueMock = vi.hoisted(() => ({
|
|||||||
addRetryFailedImportsJob: vi.fn(),
|
addRetryFailedImportsJob: vi.fn(),
|
||||||
addCleanupSeededTorrentsJob: vi.fn(),
|
addCleanupSeededTorrentsJob: vi.fn(),
|
||||||
addMonitorRssFeedsJob: vi.fn(),
|
addMonitorRssFeedsJob: vi.fn(),
|
||||||
addSyncGoodreadsShelvesJob: vi.fn(),
|
addMonitorRssFeedsJob: vi.fn(),
|
||||||
|
addSyncShelvesJob: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const configServiceMock = vi.hoisted(() => ({
|
const configServiceMock = vi.hoisted(() => ({
|
||||||
@@ -63,7 +64,9 @@ describe('SchedulerService', () => {
|
|||||||
prismaMock.scheduledJob.findFirst.mockResolvedValue(null);
|
prismaMock.scheduledJob.findFirst.mockResolvedValue(null);
|
||||||
prismaMock.scheduledJob.create.mockResolvedValue({});
|
prismaMock.scheduledJob.create.mockResolvedValue({});
|
||||||
prismaMock.scheduledJob.findMany
|
prismaMock.scheduledJob.findMany
|
||||||
|
.mockResolvedValueOnce([]) // cleanupDeprecatedJobs
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
|
// scheduleAllJobs
|
||||||
{
|
{
|
||||||
id: 'job-1',
|
id: 'job-1',
|
||||||
name: 'Audible Data Refresh',
|
name: 'Audible Data Refresh',
|
||||||
@@ -72,9 +75,10 @@ describe('SchedulerService', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([]);
|
.mockResolvedValue([]); // triggerOverdueJobs
|
||||||
|
|
||||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
const { SchedulerService } =
|
||||||
|
await import('@/lib/services/scheduler.service');
|
||||||
const service = new SchedulerService();
|
const service = new SchedulerService();
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
@@ -83,7 +87,7 @@ describe('SchedulerService', () => {
|
|||||||
'audible_refresh',
|
'audible_refresh',
|
||||||
{ scheduledJobId: 'job-1' },
|
{ scheduledJobId: 'job-1' },
|
||||||
'0 0 * * *',
|
'0 0 * * *',
|
||||||
'scheduled-job-1'
|
'scheduled-job-1',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,7 +293,7 @@ describe('SchedulerService', () => {
|
|||||||
['retry_failed_imports', 'addRetryFailedImportsJob'],
|
['retry_failed_imports', 'addRetryFailedImportsJob'],
|
||||||
['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
|
['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
|
||||||
['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
|
['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
|
||||||
['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'],
|
['sync_reading_shelves', 'addSyncShelvesJob'],
|
||||||
])('triggers %s jobs with job queue', async (type, queueMethod) => {
|
])('triggers %s jobs with job queue', async (type, queueMethod) => {
|
||||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||||
id: 'job-type',
|
id: 'job-type',
|
||||||
@@ -302,7 +306,8 @@ describe('SchedulerService', () => {
|
|||||||
(jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type');
|
(jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type');
|
||||||
prismaMock.scheduledJob.update.mockResolvedValue({});
|
prismaMock.scheduledJob.update.mockResolvedValue({});
|
||||||
|
|
||||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
const { SchedulerService } =
|
||||||
|
await import('@/lib/services/scheduler.service');
|
||||||
const service = new SchedulerService();
|
const service = new SchedulerService();
|
||||||
const jobId = await service.triggerJobNow('job-type');
|
const jobId = await service.triggerJobNow('job-type');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user