diff --git a/lib/FredyRuntime.js b/lib/FredyRuntime.js index 7693916..0b358f2 100755 --- a/lib/FredyRuntime.js +++ b/lib/FredyRuntime.js @@ -107,7 +107,7 @@ class FredyRuntime { } return !similar; }); - filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address)); + filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address)); return filteredList; } diff --git a/lib/api/api.js b/lib/api/api.js index 14dc00e..348efd2 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -3,11 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js' import { generalSettingsRouter } from './routes/generalSettingsRoute.js'; import { analyticsRouter } from './routes/analyticsRouter.js'; import { providerRouter } from './routes/providerRouter.js'; +import { versionRouter } from './routes/versionRouter.js'; import { loginRouter } from './routes/loginRoute.js'; -import { config } from '../utils.js'; import { userRouter } from './routes/userRoute.js'; import { jobRouter } from './routes/jobRouter.js'; -import { versionRouter } from './routes/versionRouter.js'; +import { config } from '../utils.js'; import bodyParser from 'body-parser'; import restana from 'restana'; import files from 'serve-static'; @@ -15,6 +15,7 @@ import path from 'path'; import { getDirName } from '../utils.js'; import { demoRouter } from './routes/demoRouter.js'; import logger from '../services/logger.js'; +import { listingsRouter } from './routes/listingsRouter.js'; const service = restana(); const staticService = files(path.join(getDirName(), '../ui/public')); const PORT = config.port || 9998; @@ -25,6 +26,8 @@ service.use(staticService); service.use('/api/admin', authInterceptor()); service.use('/api/jobs', authInterceptor()); service.use('/api/version', authInterceptor()); +service.use('/api/listings', authInterceptor()); + // /admin can only be accessed when user is having admin permissions service.use('/api/admin', adminInterceptor()); service.use('/api/jobs/notificationAdapter', notificationAdapterRouter); @@ -35,6 +38,7 @@ service.use('/api/admin/users', userRouter); service.use('/api/version', versionRouter); service.use('/api/jobs', jobRouter); service.use('/api/login', loginRouter); +service.use('/api/listings', listingsRouter); //this route is unsecured intentionally as it is being queried from the login page service.use('/api/demo', demoRouter); diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js new file mode 100644 index 0000000..96c0570 --- /dev/null +++ b/lib/api/routes/listingsRouter.js @@ -0,0 +1,23 @@ +import restana from 'restana'; +import * as listingStorage from '../../services/storage/listingsStorage.js'; +import { isAdmin as isAdminFn } from '../security.js'; +const service = restana(); + +const listingsRouter = service.newRouter(); + +listingsRouter.get('/table', async (req, res) => { + const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {}; + + const result = listingStorage.queryListings({ + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 50, + filter: filter || undefined, + sortField: sortfield || null, + sortDir: sortdir === 'desc' ? 'desc' : 'asc', + userId: req.session.currentUser, + isAdmin: isAdminFn(req), + }); + res.body = result; + res.send(); +}); +export { listingsRouter }; diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index 082767b..4bcdc78 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -166,3 +166,88 @@ export const storeListings = (jobId, providerId, listings) => { return str.replace(/\s*\([^)]*\)/g, ''); } }; + +/** + * Query listings with pagination, filtering and sorting. + * + * @param {Object} params + * @param {number} [params.pageSize=50] + * @param {number} [params.page=1] + * @param {string} [params.filter] + * @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'. + * @param {('asc'|'desc')} [params.sortDir='asc'] + * @param {string} [params.userId] - Current user id used to scope listings (ignored for admins). + * @param {boolean} [params.isAdmin=false] - When true, returns all listings. + * @returns {{ totalNumber:number, page:number, result:Object[] }} + */ +export const queryListings = ({ + pageSize = 50, + page = 1, + filter, + sortField = null, + sortDir = 'asc', + userId = null, + isAdmin = false, +} = {}) => { + // sanitize inputs + const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50; + const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const offset = (safePage - 1) * safePageSize; + + // build WHERE filter across common text columns + const whereParts = []; + const params = { limit: safePageSize, offset }; + // user scoping (non-admin only): restrict to listings whose job belongs to user + if (!isAdmin) { + params.userId = userId || '__NO_USER__'; + whereParts.push(`(j.user_id = @userId)`); + } + if (filter && String(filter).trim().length > 0) { + params.filter = `%${String(filter).trim()}%`; + whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`); + } + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; + const whereSqlWithAlias = whereSql + .replace(/\btitle\b/g, 'l.title') + .replace(/\bdescription\b/g, 'l.description') + .replace(/\baddress\b/g, 'l.address') + .replace(/\bprovider\b/g, 'l.provider') + .replace(/\blink\b/g, 'l.link') + .replace(/\bj\.user_id\b/g, 'j.user_id'); + + // whitelist sortable fields to avoid SQL injection + const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']); + const safeSortField = sortField && sortable.has(sortField) ? sortField : null; + const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC'; + const orderSqlWithAlias = orderSql + .replace(/\bcreated_at\b/g, 'l.created_at') + .replace(/\bprice\b/g, 'l.price') + .replace(/\bsize\b/g, 'l.size') + .replace(/\bprovider\b/g, 'l.provider') + .replace(/\btitle\b/g, 'l.title') + .replace(/\bjob_name\b/g, 'j.name'); + + // count total with same WHERE + const countRow = SqliteConnection.query( + `SELECT COUNT(1) as cnt + FROM listings l + LEFT JOIN jobs j ON j.id = l.job_id + ${whereSqlWithAlias}`, + params, + ); + const totalNumber = countRow?.[0]?.cnt ?? 0; + + // fetch page + const rows = SqliteConnection.query( + `SELECT l.*, j.name AS job_name + FROM listings l + LEFT JOIN jobs j ON j.id = l.job_id + ${whereSqlWithAlias} + ${orderSqlWithAlias} + LIMIT @limit OFFSET @offset`, + params, + ); + + return { totalNumber, page: safePage, result: rows }; +}; diff --git a/package.json b/package.json index 3e0b61a..f2b6a9f 100755 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@visactor/vchart": "^2.0.5", "@visactor/vchart-semi-theme": "^1.12.2", "@vitejs/plugin-react": "5.0.3", - "better-sqlite3": "^12.3.0", + "better-sqlite3": "^12.4.1", "body-parser": "2.2.0", "cheerio": "^1.1.2", "cookie-session": "2.1.1", @@ -76,14 +76,14 @@ "node-mailjet": "6.0.9", "p-throttle": "^8.0.0", "package-up": "^5.0.0", - "puppeteer": "^24.22.0", + "puppeteer": "^24.22.3", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", "query-string": "9.3.1", "react": "18.3.1", "react-dom": "18.3.1", - "react-router": "7.9.1", - "react-router-dom": "7.9.1", + "react-router": "7.9.2", + "react-router-dom": "7.9.2", "restana": "5.1.0", "serve-static": "2.2.0", "slack": "11.0.2", diff --git a/test/FredyRuntime/FredyRuntime.test.js b/test/FredyRuntime/FredyRuntime.test.js new file mode 100644 index 0000000..89817d0 --- /dev/null +++ b/test/FredyRuntime/FredyRuntime.test.js @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; +import { mockFredy } from '../utils.js'; + +describe('FredyRuntime', () => { + afterEach(() => { + similarityCache.invalidateAllForTest(); + }); + + after(() => { + similarityCache.stopCacheCleanup(); + }); + + describe('_filterBySimilarListings', () => { + let fredyRuntime; + + beforeEach(async () => { + const FredyRuntime = await mockFredy(); + fredyRuntime = new FredyRuntime({}, null, 'dummy-provider', 'dummy-job', similarityCache); + }); + + it('should filter out listings with similar title and address already in cache', () => { + similarityCache.addCacheEntry('Penthouse', 'Mustermann Straße 1'); + + const listings = [ + { id: '1', title: 'Penthouse', address: 'Mustermann Straße 1' }, + { id: '2', title: 'Nice apartment', address: 'Mustermann Straße 15' }, + ]; + + const result = fredyRuntime._filterBySimilarListings(listings); + + expect(result).to.have.length(1); + expect(result[0].id).to.equal('2'); + expect(result[0].title).to.equal('Nice apartment'); + + expect(similarityCache.hasSimilarEntries('Nice apartment', 'Mustermann Straße 15')).to.be.true; + }); + + it('should handle listings with null or undefined address', () => { + const listings = [ + { id: '1', title: 'Penthouse', address: null }, + { id: '2', title: 'Nice apartment', address: undefined }, + ]; + + const result = fredyRuntime._filterBySimilarListings(listings); + + expect(result).to.have.length(2); + + expect(similarityCache.hasSimilarEntries('Penthouse', null)).to.be.true; + expect(similarityCache.hasSimilarEntries('Nice apartment', undefined)).to.be.true; + }); + }); +}); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 5c6aa77..fbfab60 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -19,6 +19,7 @@ import './App.less'; import TrackingModal from './components/tracking/TrackingModal.jsx'; import { Banner } from '@douyinfe/semi-ui'; import VersionBanner from './components/version/VersionBanner.jsx'; +import Listings from './views/listings/Listings.jsx'; export default function FredyApp() { const actions = useActions(); @@ -50,14 +51,11 @@ export default function FredyApp() { const isAdmin = () => currentUser != null && currentUser.isAdmin; - const login = () => ( + return loading ? null : needsLogin() ? ( } /> } /> - ); - return loading ? null : needsLogin() ? ( - login() ) : (
@@ -84,6 +82,7 @@ export default function FredyApp() { } /> } /> } /> + } /> {/* Permission-aware routes */} ; + return Fredy Logo; } diff --git a/ui/src/components/menu/Menu.jsx b/ui/src/components/menu/Menu.jsx index 1f4cc74..f49f9a8 100644 --- a/ui/src/components/menu/Menu.jsx +++ b/ui/src/components/menu/Menu.jsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Tabs, TabPane } from '@douyinfe/semi-ui'; import { useLocation } from 'react-router-dom'; -import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons'; +import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons'; import './Menu.less'; function parsePathName(name) { @@ -25,6 +25,15 @@ const TopMenu = function TopMenu({ isAdmin }) { } /> + + + Found listings + + } + /> {isAdmin && ( { + return value ? ( +
+ + + +
+ ) : ( +
+ + + +
+ ); + }, + }, + { + title: 'Job-Name', + sorter: true, + dataIndex: 'job_name', + width: 170, + }, + { + title: 'Listing date', + width: 130, + dataIndex: 'created_at', + sorter: true, + render: (text) => timeService.format(text), + }, + { + title: 'Provider', + width: 130, + dataIndex: 'provider', + sorter: true, + render: (text) => text.charAt(0).toUpperCase() + text.slice(1), + }, + { + title: 'Price', + width: 100, + dataIndex: 'price', + sorter: true, + render: (text) => text + ' €', + }, + { + title: 'Address', + width: 150, + dataIndex: 'address', + sorter: true, + }, + { + title: 'Title', + dataIndex: 'title', + sorter: true, + render: (text, row) => { + return ( + + {text} + + ); + }, + }, +]; + +export default function ListingsTable() { + const tableData = useSelector((state) => state.listingsTable); + const actions = useActions(); + const [page, setPage] = useState(1); + const pageSize = 15; + const [sortData, setSortData] = useState({}); + const [filter, setFilter] = useState(null); + + const handlePageChange = (_page) => { + setPage(_page); + }; + + useEffect(() => { + let sortfield = null; + let sortdir = null; + + if (sortData != null && Object.keys(sortData).length > 0) { + sortfield = sortData.field; + sortdir = sortData.direction; + } + actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter }); + }, [page, sortData, filter]); + + const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []); + + const expandRowRender = (record) => { + return ( +
+
+ {record.image_url == null ? ( + + ) : ( + + )} +
+
+ + + + {record.is_active ? 'Yes' : 'No'} + + + + + Link to Listing + + + {format(record.created_at)} + {record.price} € + + {record.title} +

{record.description == null ? 'No description available' : record.description}

+
+
+ ); + }; + + return ( +
+ } + showClear + className="listingsTable__search" + placeholder="Search" + onChange={handleFilterChange} + /> + { + if (changeSet?.extra?.changeType === 'sorter') { + setSortData({ + field: changeSet.sorter.dataIndex, + direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc', + }); + } + }} + pagination={{ + currentPage: page, + //for now fixed + pageSize, + total: tableData?.totalNumber || 0, + onPageChange: handlePageChange, + }} + /> + + ); +} diff --git a/ui/src/components/table/ListingsTable.less b/ui/src/components/table/ListingsTable.less new file mode 100644 index 0000000..015e85e --- /dev/null +++ b/ui/src/components/table/ListingsTable.less @@ -0,0 +1,10 @@ +.listingsTable { + &__search { + margin-bottom: 1rem !important; + } + + &__expanded { + display: flex; + gap: 1rem; + } +} \ No newline at end of file diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 6706096..60275ff 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -4,6 +4,7 @@ import { create } from 'zustand'; import { shallow } from 'zustand/shallow'; import { xhrGet } from '../xhr.js'; +import queryString from 'query-string'; const logger = (config) => (set, get, api) => config( @@ -130,11 +131,35 @@ export const useFredyState = create( } }, }, + listingsTable: { + async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) { + try { + const qryString = queryString.stringify({ + page, + pageSize, + filter, + sortfield, + sortdir, + }); + const response = await xhrGet(`/api/listings/table?${qryString}`); + set((state) => ({ + listingsTable: { ...state.listingsTable, ...response.json }, + })); + } catch (Exception) { + console.error('Error while trying to get resource for api/listings. Error:', Exception); + } + }, + }, }; // Initial state const initial = { notificationAdapter: [], + listingsTable: { + totalNumber: 0, + page: 1, + result: [], + }, generalSettings: { settings: {} }, demoMode: { demoMode: false }, versionUpdate: {}, @@ -149,6 +174,7 @@ export const useFredyState = create( generalSettings: { ...effects.generalSettings }, demoMode: { ...effects.demoMode }, versionUpdate: { ...effects.versionUpdate }, + listingsTable: { ...effects.listingsTable }, provider: { ...effects.provider }, jobs: { ...effects.jobs }, user: { ...effects.user }, diff --git a/ui/src/views/listings/Listings.jsx b/ui/src/views/listings/Listings.jsx new file mode 100644 index 0000000..308484d --- /dev/null +++ b/ui/src/views/listings/Listings.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import ListingsTable from '../../components/table/ListingsTable.jsx'; + +export default function Listings() { + return ( +
+ +
+ ); +} diff --git a/yarn.lock b/yarn.lock index 9b7f570..631f3a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,10 +2197,10 @@ basic-ftp@^5.0.2: resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0" integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg== -better-sqlite3@^12.3.0: - version "12.3.0" - resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.3.0.tgz#999817506ed9d985604ae053b5e5fe3c8a052bb1" - integrity sha512-FFf+rsghyvXQIPV/6PDUj05EsuZA1b0drGLzNgtrELkXnJKUH6NNM2h7Ce7dkA6vvPOM4SOoUIDGRPy3yRKmqw== +better-sqlite3@^12.4.1: + version "12.4.1" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.4.1.tgz#f78df6c80530d1a0b750b538033e6199b7d30d26" + integrity sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ== dependencies: bindings "^1.5.0" prebuild-install "^7.1.1" @@ -2451,10 +2451,10 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== -chromium-bidi@8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-8.0.0.tgz#d73c9beed40317adf2bcfeb9a47087003cd467ec" - integrity sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g== +chromium-bidi@9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-9.1.0.tgz#356eaea018eecc7977644305ee9fd27874b2b676" + integrity sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA== dependencies: mitt "^3.0.1" zod "^3.24.1" @@ -5962,13 +5962,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -puppeteer-core@24.22.0: - version "24.22.0" - resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.0.tgz#4d576b1a2b7699c088d3f0e843c32d81df82c3a6" - integrity sha512-oUeWlIg0pMz8YM5pu0uqakM+cCyYyXkHBxx9di9OUELu9X9+AYrNGGRLK9tNME3WfN3JGGqQIH3b4/E9LGek/w== +puppeteer-core@24.22.3: + version "24.22.3" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23" + integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw== dependencies: "@puppeteer/browsers" "2.10.10" - chromium-bidi "8.0.0" + chromium-bidi "9.1.0" debug "^4.4.3" devtools-protocol "0.0.1495869" typed-query-selector "^2.12.0" @@ -6022,16 +6022,16 @@ puppeteer-extra@^3.3.6: debug "^4.1.1" deepmerge "^4.2.2" -puppeteer@^24.22.0: - version "24.22.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.0.tgz#9f6905e9c3d5c316c364adb598903a1dfbfe800f" - integrity sha512-QabGIvu7F0hAMiKGHZCIRHMb6UoH0QAJA2OaqxEU2tL5noXPrxUcotg2l3ttOA4p1PFnVIGkr6PXRAWlM2evVQ== +puppeteer@^24.22.3: + version "24.22.3" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e" + integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ== dependencies: "@puppeteer/browsers" "2.10.10" - chromium-bidi "8.0.0" + chromium-bidi "9.1.0" cosmiconfig "^9.0.0" devtools-protocol "0.0.1495869" - puppeteer-core "24.22.0" + puppeteer-core "24.22.3" typed-query-selector "^2.12.0" qs@^6.14.0: @@ -6121,17 +6121,17 @@ react-resizable@^3.0.5: prop-types "15.x" react-draggable "^4.0.3" -react-router-dom@7.9.1: - version "7.9.1" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.1.tgz#48044923701773da6362f9003ec46f308f293f15" - integrity sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw== +react-router-dom@7.9.2: + version "7.9.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26" + integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w== dependencies: - react-router "7.9.1" + react-router "7.9.2" -react-router@7.9.1: - version "7.9.1" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.1.tgz#b227410c31f24dd416c939ca5d0f8d5c8a1404d4" - integrity sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g== +react-router@7.9.2: + version "7.9.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6" + integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0"