diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index c5968f521..3aac30065 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -26,6 +26,52 @@ class MeController { res.json(req.user.toOldJSONForBrowser()) } + /** + * GET: /api/me/progress + * + * @param {RequestWithUser} req + * @param {Response} res + */ + getAllMediaProgress(req, res) { + const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [] + res.json(mediaProgress) + } + + /** + * GET: /api/me/bookmarks + * + * @param {RequestWithUser} req + * @param {Response} res + */ + getAllBookmarks(req, res) { + const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || [] + res.json(bookmarks) + } + + /** + * GET: /api/me/bookmarks/:libraryItemId + * + * Podcast episodes belong to their podcast's library item, so bookmarks for + * a podcast are retrieved with the podcast library item id. + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getBookmarksForLibraryItem(req, res) { + const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId) + if (!libraryItem) { + return res.sendStatus(404) + } + + if (!req.user.checkCanAccessLibraryItem(libraryItem)) { + Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`) + return res.sendStatus(403) + } + + const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || [] + res.json(bookmarks) + } + /** * GET: /api/me/listening-sessions * diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e89b364f3..1702c9b37 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -171,6 +171,9 @@ class ApiRouter { // Current User Routes (Me) // this.router.get('/me', MeController.getCurrentUser.bind(this)) + this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this)) + this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this)) + this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this)) this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this)) this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this)) this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this)) diff --git a/test/server/controllers/MeController.test.js b/test/server/controllers/MeController.test.js index 3cc5496d9..9fd9eb12d 100644 --- a/test/server/controllers/MeController.test.js +++ b/test/server/controllers/MeController.test.js @@ -160,6 +160,164 @@ describe('MeController - IDOR Security Tests', () => { }) }) + describe('current user progress and bookmark reads', () => { + let user1, user2 + let bookLibraryItem, podcastLibraryItem, podcastEpisode + + beforeEach(async () => { + user1 = await Database.userModel.create({ + username: 'user1', + pash: 'hashed_password_1', + type: 'user', + isActive: true, + permissions: { + accessAllLibraries: true, + accessAllTags: true + } + }) + user2 = await Database.userModel.create({ + username: 'user2', + pash: 'hashed_password_2', + type: 'user', + isActive: true, + permissions: { + accessAllLibraries: false, + accessAllTags: true, + librariesAccessible: [] + } + }) + + const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' }) + const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id }) + const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + bookLibraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: bookLibrary.id, + libraryFolderId: bookLibraryFolder.id + }) + + const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' }) + const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id }) + const podcast = await Database.podcastModel.create({ title: 'Test Podcast', tags: [], genres: [] }) + podcastLibraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: podcast.id, + mediaType: 'podcast', + libraryId: podcastLibrary.id, + libraryFolderId: podcastLibraryFolder.id + }) + podcastEpisode = await Database.podcastEpisodeModel.create({ + podcastId: podcast.id, + title: 'Episode 1', + index: 1, + audioFile: { ino: '1', metadata: { filename: 'episode-1.mp3', ext: '.mp3', path: '/podcasts/episode-1.mp3', relPath: 'episode-1.mp3' } } + }) + + await Database.mediaProgressModel.create({ + userId: user1.id, + mediaItemId: book.id, + mediaItemType: 'book', + duration: 1000, + currentTime: 500, + isFinished: false, + extraData: { libraryItemId: bookLibraryItem.id, progress: 0.5 } + }) + await Database.mediaProgressModel.create({ + userId: user1.id, + mediaItemId: podcastEpisode.id, + podcastId: podcast.id, + mediaItemType: 'podcastEpisode', + duration: 1000, + currentTime: 250, + isFinished: false, + extraData: { libraryItemId: podcastLibraryItem.id, progress: 0.25 } + }) + await Database.mediaProgressModel.create({ + userId: user2.id, + mediaItemId: book.id, + mediaItemType: 'book', + duration: 1000, + currentTime: 750, + isFinished: false, + extraData: { libraryItemId: bookLibraryItem.id, progress: 0.75 } + }) + + user1.mediaProgresses = await user1.getMediaProgresses() + user2.mediaProgresses = await user2.getMediaProgresses() + user1.bookmarks = [ + { libraryItemId: bookLibraryItem.id, time: 100, title: 'Book Bookmark' }, + { libraryItemId: podcastLibraryItem.id, episodeId: podcastEpisode.id, time: 200, title: 'Episode Bookmark' } + ] + user2.bookmarks = [{ libraryItemId: bookLibraryItem.id, time: 300, title: 'Other User Bookmark' }] + }) + + it('should register the current-user progress and bookmark read routes', () => { + const routes = apiRouter.router._router.stack.filter((layer) => layer.route).map((layer) => `${Object.keys(layer.route.methods)[0].toUpperCase()} ${layer.route.path}`) + + expect(routes).to.include('GET /me/progress') + expect(routes).to.include('GET /me/bookmarks') + expect(routes).to.include('GET /me/bookmarks/:libraryItemId') + }) + + it("should return only the authenticated user's media progress, including podcast episode progress", () => { + const fakeRes = { json: sinon.spy() } + + MeController.getAllMediaProgress({ user: user1 }, fakeRes) + + const mediaProgress = fakeRes.json.firstCall.args[0] + expect(mediaProgress).to.have.length(2) + expect(mediaProgress.every((progress) => progress.userId === user1.id)).to.be.true + const podcastProgress = mediaProgress.find((progress) => progress.mediaItemId === podcastEpisode.id) + expect(podcastProgress).to.include({ + id: user1.mediaProgresses.find((progress) => progress.mediaItemId === podcastEpisode.id).id, + userId: user1.id, + libraryItemId: podcastLibraryItem.id, + episodeId: podcastEpisode.id, + mediaItemId: podcastEpisode.id, + mediaItemType: 'podcastEpisode', + duration: 1000, + progress: 0.25, + currentTime: 250, + isFinished: false, + hideFromContinueListening: false, + ebookLocation: null, + ebookProgress: null, + finishedAt: null + }) + expect(podcastProgress.lastUpdate).to.be.a('number') + expect(podcastProgress.startedAt).to.be.a('number') + }) + + it("should return only the authenticated user's bookmarks", () => { + const fakeRes = { json: sinon.spy() } + + MeController.getAllBookmarks({ user: user1 }, fakeRes) + + expect(fakeRes.json.calledWith(user1.bookmarks)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.not.deep.include(user2.bookmarks[0]) + }) + + it('should return podcast episode bookmarks using the parent podcast library item id', async () => { + const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } + + await MeController.getBookmarksForLibraryItem({ user: user1, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) + + expect(fakeRes.sendStatus.notCalled).to.be.true + expect(fakeRes.json.calledWith([user1.bookmarks[1]])).to.be.true + }) + + it('should prevent a user from reading bookmarks for an inaccessible library item', async () => { + const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } + + await MeController.getBookmarksForLibraryItem({ user: user2, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) + + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + expect(fakeRes.json.notCalled).to.be.true + }) + }) + describe('Bookmark Operations - Authorization Checks', () => { let user1, user2 let library1, library2