moving from restana to fastify

This commit is contained in:
orangecoding
2026-04-27 16:56:04 +02:00
parent fef6d06a9d
commit 3d10dc6042
41 changed files with 1307 additions and 3465 deletions

View File

@@ -8,7 +8,7 @@ Fredy is a self-hosted real estate finder for Germany. It scrapes German real es
- Node.js >= 22, ESM-only (`"type": "module"`) - Node.js >= 22, ESM-only (`"type": "module"`)
- Default port: 9998, default login: admin / admin - Default port: 9998, default login: admin / admin
- SQLite via `better-sqlite3` (synchronous all DB ops are sync; only network I/O is async) - SQLite via `better-sqlite3` (synchronous - all DB ops are sync; only network I/O is async)
## Commands ## Commands
@@ -66,13 +66,13 @@ scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
### Plugin systems ### Plugin systems
**Providers** (`lib/provider/*.js`) each module exports: **Providers** (`lib/provider/*.js`) - each module exports:
- `metaInformation` `{ id, name, baseUrl }` - `metaInformation` - `{ id, name, baseUrl }`
- `config` `ProviderConfig` with `requiredFieldNames`, `crawlContainer`, `crawlFields`, `sortByDateParam`, `normalize()`, `filter()`, optional `getListings()`, `fetchDetails()`, `activeTester()` - `config` - `ProviderConfig` with `requiredFieldNames`, `crawlContainer`, `crawlFields`, `sortByDateParam`, `normalize()`, `filter()`, optional `getListings()`, `fetchDetails()`, `activeTester()`
- `init(sourceConfig, blacklist)` called before each job run; providers are **stateful modules** holding mutable `url` and `appliedBlackList` at module scope - `init(sourceConfig, blacklist)` - called before each job run; providers are **stateful modules** holding mutable `url` and `appliedBlackList` at module scope
**Notification adapters** (`lib/notification/adapter/*.js`) each exports: **Notification adapters** (`lib/notification/adapter/*.js`) - each exports:
- `config` `{ id, name, description, fields }` (drives the UI form) - `config` - `{ id, name, description, fields }` (drives the UI form)
- `send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })` - `send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })`
- Loaded dynamically at startup via `fs.readdirSync` - Loaded dynamically at startup via `fs.readdirSync`
@@ -98,18 +98,18 @@ scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
### MCP server ### MCP server
Two transports: Two transports:
1. **stdio** (`lib/mcp/stdio.js`) for Claude Desktop/LM Studio; opens its own DB connection (main process need not be running) 1. **stdio** (`lib/mcp/stdio.js`) - for Claude Desktop/LM Studio; opens its own DB connection (main process need not be running)
2. **HTTP** (`/api/mcp`) authenticated via Bearer token (`mcp_token` column in `users` table) 2. **HTTP** (`/api/mcp`) - authenticated via Bearer token (`mcp_token` column in `users` table)
Tools: `list_jobs`, `get_job`, `list_listings`, `get_listing`, `get_current_date_time`. Responses are Markdown via `lib/mcp/mcpNormalizer.js`. Tools: `list_jobs`, `get_job`, `list_listings`, `get_listing`, `get_current_date_time`. Responses are Markdown via `lib/mcp/mcpNormalizer.js`.
## Key Conventions ## Key Conventions
- **ESM only** `import`/`export` everywhere, no CommonJS - **ESM only** - `import`/`export` everywhere, no CommonJS
- **JSDoc typedefs** (no TypeScript) in `lib/types/` `listing.js`, `job.js`, `filter.js`, `providerConfig.js` - **JSDoc typedefs** (no TypeScript) in `lib/types/` - `listing.js`, `job.js`, `filter.js`, `providerConfig.js`
- **Copyright header** required on all `.js` files enforced by `lint-staged` pre-commit hook via `copyright.js` - **Copyright header** required on all `.js` files - enforced by `lint-staged` pre-commit hook via `copyright.js`
- **`NoNewListingsWarning`** (`lib/errors.js`) is used as control flow to short-circuit the pipeline (not an error) - **`NoNewListingsWarning`** (`lib/errors.js`) is used as control flow to short-circuit the pipeline (not an error)
- **Test fixtures** in `test/testFixtures/` HTML/JSON snapshots per provider; `TEST_MODE=offline` mocks `puppeteerExtractor` and global `fetch` via `test/offlineFixtures.js` - **Test fixtures** in `test/testFixtures/` - HTML/JSON snapshots per provider; `TEST_MODE=offline` mocks `puppeteerExtractor` and global `fetch` via `test/offlineFixtures.js`
- **`conf/config.json`** is the only runtime config file; created with defaults if missing - **`conf/config.json`** is the only runtime config file; created with defaults if missing
## Coding ## Coding

View File

@@ -43,13 +43,13 @@ for i in $(seq 1 30); do
done done
# Verify the DB is readable/writable via the API. # Verify the DB is readable/writable via the API.
# /api/demo is unauthenticated and reads the settings table if SQLite is broken this returns an error. # /api/demo is unauthenticated and reads the settings table - if SQLite is broken this returns an error.
echo "Testing DB via API (/api/demo)..." echo "Testing DB via API (/api/demo)..."
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1) DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
echo "DB is readable (got demoMode from /api/demo)" echo "DB is readable (got demoMode from /api/demo)"
else else
echo "DB check failed unexpected response from /api/demo: $DEMO_RESPONSE" echo "DB check failed - unexpected response from /api/demo: $DEMO_RESPONSE"
docker logs fredy docker logs fredy
exit 1 exit 1
fi fi

File diff suppressed because it is too large Load Diff

View File

@@ -3,64 +3,100 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js'; import Fastify from 'fastify';
import { authInterceptor, cookieSession, adminInterceptor } from './security.js'; import fastifyHelmet from '@fastify/helmet';
import { generalSettingsRouter } from './routes/generalSettingsRoute.js'; import fastifyCookie from '@fastify/cookie';
import { providerRouter } from './routes/providerRouter.js'; import fastifySession from '@fastify/session';
import { versionRouter } from './routes/versionRouter.js'; import fastifyStatic from '@fastify/static';
import { loginRouter } from './routes/loginRoute.js';
import { userRouter } from './routes/userRoute.js';
import { userSettingsRouter } from './routes/userSettingsRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import bodyParser from 'body-parser';
import restana from 'restana';
import files from 'serve-static';
import path from 'path'; import path from 'path';
import { getDirName } from '../utils.js'; import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js'; import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js'; import logger from '../services/logger.js';
import { backupRouter } from './routes/backupRouter.js'; import { authHook, adminHook } from './security.js';
import { trackingRouter } from './routes/trackingRoute.js';
import loginPlugin from './routes/loginRoute.js';
import demoPlugin from './routes/demoRouter.js';
import jobPlugin from './routes/jobRouter.js';
import versionPlugin from './routes/versionRouter.js';
import listingsPlugin from './routes/listingsRouter.js';
import dashboardPlugin from './routes/dashboardRouter.js';
import userSettingsPlugin from './routes/userSettingsRoute.js';
import trackingPlugin from './routes/trackingRoute.js';
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
import backupPlugin from './routes/backupRouter.js';
import userPlugin from './routes/userRoute.js';
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
import providerPlugin from './routes/providerRouter.js';
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js'; import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998; const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret(); const sessionSecret = await getOrCreateSessionSecret();
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000;
service.use(bodyParser.json()); const fastify = Fastify({
service.use(cookieSession(sessionSecret)); logger: false,
service.use(staticService); bodyLimit: 50 * 1024 * 1024, // 50 MB for backup uploads
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/user/settings', authInterceptor());
service.use('/api/tracking', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/admin/backup', backupRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/user/settings', userSettingsRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/dashboard', dashboardRouter);
service.use('/api/tracking', trackingRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
registerMcpRoutes(service);
service.start(PORT).then(() => {
logger.debug(`Started API service on port ${PORT}`);
}); });
// Security headers (CSP disabled to avoid breaking the SPA)
await fastify.register(fastifyHelmet, { contentSecurityPolicy: false });
// Cookie + session (in-memory store, signed cookie)
await fastify.register(fastifyCookie);
await fastify.register(fastifySession, {
secret: sessionSecret,
cookieName: 'fredy-admin-session',
cookie: {
maxAge: SESSION_MAX_AGE,
httpOnly: true,
secure: false,
sameSite: 'lax',
},
saveUninitialized: false,
});
// Serve the React SPA from ui/public/
await fastify.register(fastifyStatic, {
root: path.join(getDirName(), '../ui/public'),
wildcard: false,
});
// Public routes - no auth required
fastify.register(loginPlugin, { prefix: '/api/login' });
fastify.register(demoPlugin, { prefix: '/api/demo' });
// User-authenticated routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.register(jobPlugin, { prefix: '/api/jobs' });
app.register(notificationAdapterPlugin, { prefix: '/api/jobs/notificationAdapter' });
app.register(providerPlugin, { prefix: '/api/jobs/provider' });
app.register(versionPlugin, { prefix: '/api/version' });
app.register(listingsPlugin, { prefix: '/api/listings' });
app.register(dashboardPlugin, { prefix: '/api/dashboard' });
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
app.register(trackingPlugin, { prefix: '/api/tracking' });
});
// Admin-only routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.addHook('preHandler', adminHook);
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
app.register(backupPlugin, { prefix: '/api/admin/backup' });
app.register(userPlugin, { prefix: '/api/admin/users' });
});
// MCP Streamable HTTP (Bearer token auth - no session)
registerMcpRoutes(fastify);
// SPA fallback - serve index.html for all non-API GET requests
fastify.setNotFoundHandler((request, reply) => {
if (!request.url.startsWith('/api/')) {
return reply.sendFile('index.html');
}
return reply.code(404).send({ error: 'Not found' });
});
await fastify.listen({ port: PORT, host: '0.0.0.0' });
logger.debug(`Started API service on port ${PORT}`);

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { import {
buildBackupFileName, buildBackupFileName,
createBackupZip, createBackupZip,
@@ -12,64 +11,41 @@ import {
} from '../../services/storage/backupRestoreService.js'; } from '../../services/storage/backupRestoreService.js';
/** /**
* Backup & Restore Admin Router * @param {import('fastify').FastifyInstance} fastify
*
* Endpoints:
* - GET /api/admin/backup
* Returns the current database as a zip download. Content-Type: application/zip
* - POST /api/admin/backup/restore?dryRun=true
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
* - POST /api/admin/backup/restore?force=true|false
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
*/ */
const service = restana(); export default async function backupPlugin(fastify) {
const backupRouter = service.newRouter(); // Parse raw binary uploads as Buffer
fastify.addContentTypeParser(
['application/zip', 'application/octet-stream'],
{ parseAs: 'buffer' },
(req, body, done) => done(null, body),
);
backupRouter.get('/', async (req, res) => { fastify.get('/', async (_request, reply) => {
const zipBuffer = await createBackupZip(); const zipBuffer = await createBackupZip();
const fileName = await buildBackupFileName(); const fileName = await buildBackupFileName();
res.setHeader('Content-Type', 'application/zip'); reply.header('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
res.send(zipBuffer); return reply.send(zipBuffer);
}); });
/** fastify.post('/restore', async (request, reply) => {
* Read the full request body as a Buffer. Used for raw zip uploads. const { dryRun = 'false', force = 'false' } = request.query || {};
* @param {import('http').IncomingMessage} req const doDryRun = String(dryRun) === 'true';
* @returns {Promise<Buffer>} const doForce = String(force) === 'true';
*/ const body = request.body; // Buffer from addContentTypeParser
function readBody(req) {
return new Promise((resolve, reject) => { if (doDryRun) {
const chunks = []; return precheckRestore(body);
req.on('data', (c) => chunks.push(c)); }
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', (e) => reject(e)); try {
return restoreFromZip(body, { force: doForce });
} catch (e) {
return reply.code(400).send({
message: e?.message || 'Restore failed',
details: e?.payload || null,
});
}
}); });
} }
// Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream)
// Query parameters:
// - dryRun=true => only validate and return compatibility info
// - force=true => proceed even if incompatible
backupRouter.post('/restore', async (req, res) => {
const { dryRun = 'false', force = 'false' } = req.query || {};
const doDryRun = String(dryRun) === 'true';
const doForce = String(force) === 'true';
const body = await readBody(req);
if (doDryRun) {
res.body = await precheckRestore(body);
return res.send();
}
try {
res.body = await restoreFromZip(body, { force: doForce });
return res.send();
} catch (e) {
res.statusCode = 400;
res.body = { message: e?.message || 'Restore failed', details: e?.payload || null };
return res.send();
}
});
export { backupRouter };

View File

@@ -3,23 +3,14 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js'; import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
const service = restana(); function getAccessibleJobs(request) {
export const dashboardRouter = service.newRouter(); const currentUser = request.session.currentUser;
const admin = isAdmin(request);
function isAdmin(req) {
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
return !!user?.isAdmin;
}
function getAccessibleJobs(req) {
const currentUser = req.session.currentUser;
const admin = isAdmin(req);
return jobStorage return jobStorage
.getJobs() .getJobs()
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser)); .filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
@@ -29,43 +20,45 @@ function cap(val) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1); return String(val).charAt(0).toUpperCase() + String(val).slice(1);
} }
dashboardRouter.get('/', async (req, res) => { /**
const jobs = getAccessibleJobs(req); * @param {import('fastify').FastifyInstance} fastify
const settings = await getSettings(); */
export default async function dashboardPlugin(fastify) {
fastify.get('/', async (request) => {
const jobs = getAccessibleJobs(request);
const settings = await getSettings();
// KPIs const totalJobs = jobs.length;
const totalJobs = jobs.length; const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0); const jobIds = jobs.map((j) => j.id);
const jobIds = jobs.map((j) => j.id); const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
// Build Pie data in a simple shape the frontend can consume directly const providerPieRaw = getProviderDistributionForJobIds(jobIds);
// Shape: { labels: string[], values: number[] } with values as percentages const providerPie = Array.isArray(providerPieRaw)
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
const providerPie = Array.isArray(providerPieRaw)
? {
labels: providerPieRaw.map((p) => cap(p.type)),
values: providerPieRaw.map((p) => Number(p.value) || 0),
}
: providerPieRaw && typeof providerPieRaw === 'object'
? { ? {
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [], labels: providerPieRaw.map((p) => cap(p.type)),
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [], values: providerPieRaw.map((p) => Number(p.value) || 0),
} }
: { labels: [], values: [] }; : providerPieRaw && typeof providerPieRaw === 'object'
? {
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
}
: { labels: [], values: [] };
res.body = { return {
general: { general: {
interval: settings.interval, interval: settings.interval,
lastRun: settings.lastRun || null, lastRun: settings.lastRun || null,
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000, nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
}, },
kpis: { kpis: {
totalJobs, totalJobs,
totalListings, totalListings,
numberOfActiveListings, numberOfActiveListings,
medianPriceOfListings, medianPriceOfListings,
}, },
pie: providerPie, pie: providerPie,
}; };
res.send(); });
}); }

View File

@@ -3,15 +3,14 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const demoRouter = service.newRouter();
demoRouter.get('/', async (req, res) => { /**
const settings = await getSettings(); * @param {import('fastify').FastifyInstance} fastify
res.body = Object.assign({}, { demoMode: settings.demoMode }); */
res.send(); export default async function demoPlugin(fastify) {
}); fastify.get('/', async () => {
const settings = await getSettings();
export { demoRouter }; return { demoMode: settings.demoMode };
});
}

View File

@@ -3,43 +3,42 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { getDirName } from '../../utils.js'; import { getDirName } from '../../utils.js';
import fs from 'fs'; import fs from 'fs';
import { ensureDemoUserExists } from '../../services/storage/userStorage.js'; import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js'; import { isAdmin } from '../security.js';
const service = restana();
const generalSettingsRouter = service.newRouter();
generalSettingsRouter.get('/', async (req, res) => { /**
res.body = Object.assign({}, await getSettings()); * @param {import('fastify').FastifyInstance} fastify
res.send(); */
}); export default async function generalSettingsPlugin(fastify) {
generalSettingsRouter.post('/', async (req, res) => { fastify.get('/', async () => {
const { sqlitepath, ...appSettings } = req.body || {}; return Object.assign({}, await getSettings());
if (typeof appSettings.baseUrl === 'string') { });
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
}
const localSettings = await getSettings();
if (localSettings.demoMode && !isAdmin(req)) { fastify.post('/', async (request, reply) => {
res.send(new Error('In demo mode, it is not allowed to change these settings.')); const { sqlitepath, ...appSettings } = request.body || {};
return; if (typeof appSettings.baseUrl === 'string') {
} appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
try {
if (typeof sqlitepath !== 'undefined') {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
} }
upsertSettings(appSettings); const localSettings = await getSettings();
ensureDemoUserExists();
} catch (err) { if (localSettings.demoMode && !isAdmin(request)) {
logger.error(err); return reply.code(403).send({ error: 'In demo mode, it is not allowed to change these settings.' });
res.send(new Error('Error while trying to write settings.')); }
return;
} try {
res.send(); if (typeof sqlitepath !== 'undefined') {
}); fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
export { generalSettingsRouter }; }
upsertSettings(appSettings);
ensureDemoUserExists();
} catch (err) {
logger.error(err);
return reply.code(500).send({ error: 'Error while trying to write settings.' });
}
return reply.send();
});
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js'; import * as userStorage from '../../services/storage/userStorage.js';
import { isAdmin } from '../security.js'; import { isAdmin } from '../security.js';
@@ -13,257 +12,234 @@ import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js'; import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const jobRouter = service.newRouter();
const DEMO_JOB_NAME = 'Demo-Job'; const DEMO_JOB_NAME = 'Demo-Job';
function doesJobBelongsToUser(job, req) { function doesJobBelongsToUser(job, request) {
const userId = req.session.currentUser; const userId = request.session.currentUser;
if (userId == null) { if (userId == null) return false;
return false;
}
const user = userStorage.getUser(userId); const user = userStorage.getUser(userId);
if (user == null) { if (user == null) return false;
return false;
}
return user.isAdmin || job.userId === user.id; return user.isAdmin || job.userId === user.id;
} }
jobRouter.get('/', async (req, res) => { /**
const isUserAdmin = isAdmin(req); * @param {import('fastify').FastifyInstance} fastify
//show only the jobs which belongs to the user (or all of the user is an admin) */
res.body = jobStorage export default async function jobPlugin(fastify) {
.getJobs() fastify.get('/', async (request) => {
.filter( const isUserAdmin = isAdmin(request);
(job) => return jobStorage
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser), .getJobs()
) .filter(
.map((job) => { (job) =>
return { isUserAdmin ||
job.userId === request.session.currentUser ||
job.shared_with_user.includes(request.session.currentUser),
)
.map((job) => ({
...job, ...job,
running: isJobRunning(job.id), running: isJobRunning(job.id),
isOnlyShared: isOnlyShared:
!isUserAdmin && !isUserAdmin &&
job.userId !== req.session.currentUser && job.userId !== request.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser), job.shared_with_user.includes(request.session.currentUser),
}; }));
});
res.send();
});
jobRouter.get('/data', async (req, res) => {
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
// normalize booleans
const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const queryResult = jobStorage.queryJobs({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
freeTextFilter: freeTextFilter || null,
activityFilter: normalizedActivity,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser,
isAdmin: isAdmin(req),
}); });
const isUserAdmin = isAdmin(req); fastify.get('/data', async (request) => {
const {
page,
pageSize = 50,
activityFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = request.query || {};
// Map result to include runtime status const toBool = (v) => {
queryResult.result = queryResult.result.map((job) => { if (v === true || v === 'true' || v === 1 || v === '1') return true;
return { if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const queryResult = jobStorage.queryJobs({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
freeTextFilter: freeTextFilter || null,
activityFilter: normalizedActivity,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser,
isAdmin: isAdmin(request),
});
const isUserAdmin = isAdmin(request);
queryResult.result = queryResult.result.map((job) => ({
...job, ...job,
running: isJobRunning(job.id), running: isJobRunning(job.id),
isOnlyShared: isOnlyShared:
!isUserAdmin && !isUserAdmin &&
job.userId !== req.session.currentUser && job.userId !== request.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser), job.shared_with_user.includes(request.session.currentUser),
}; }));
return queryResult;
}); });
res.body = queryResult; // Server-Sent Events for real-time job status updates
res.send(); fastify.get('/events', async (request, reply) => {
}); const userId = request.session?.currentUser;
if (userId == null) {
return reply.code(401).send({ message: 'Unauthorized' });
}
reply.hijack();
const raw = reply.raw;
raw.setHeader('Content-Type', 'text/event-stream');
raw.setHeader('Cache-Control', 'no-cache');
raw.setHeader('Connection', 'keep-alive');
// Server-Sent Events for job status updates
jobRouter.get('/events', async (req, res) => {
const userId = req.session.currentUser;
if (userId == null) {
res.send({ message: 'Unauthorized' }, 401);
return;
}
// SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// Initial comment to establish stream
res.write(': connected\n\n');
addSseClient(userId, res);
// Cleanup on close/aborted
const onClose = () => removeClient(userId, res);
// restana exposes original req/res; use both close and finish
req.on('close', onClose);
req.on('aborted', onClose);
res.on('close', onClose);
} catch (e) {
logger.error('Error establishing SSE connection', e);
try { try {
res.end(); raw.write(': connected\n\n');
} catch { addSseClient(userId, raw);
//noop const onClose = () => removeClient(userId, raw);
request.raw.on('close', onClose);
} catch (e) {
logger.error('Error establishing SSE connection', e);
try {
raw.end();
} catch {
/* noop */
}
} }
} });
});
jobRouter.post('/startAll', async (req, res) => { fastify.post('/startAll', async (request, reply) => {
try { try {
const userId = req.session.currentUser; const userId = request.session.currentUser;
// Emit only the userId; handler will decide based on admin/ownership bus.emit('jobs:runAll', { userId });
bus.emit('jobs:runAll', { userId }); return reply.code(202).send({ message: 'Run all accepted' });
res.send({ message: 'Run all accepted' }, 202); } catch (err) {
} catch (err) { logger.error('Failed to trigger startAll', err);
logger.error('Failed to trigger startAll', err); return reply.code(500).send({ message: 'Unexpected error' });
res.send({ message: 'Unexpected error' }, 500);
}
});
// Trigger a single job run
jobRouter.post('/:jobId/run', async (req, res) => {
const { jobId } = req.params;
try {
const job = jobStorage.getJob(jobId);
if (!job) {
res.send({ message: 'Job not found' }, 404);
return;
} }
if (!doesJobBelongsToUser(job, req)) { });
res.send({ message: 'You are trying to run a job that is not associated to your user' }, 403);
return;
}
if (isJobRunning(jobId)) {
res.send({ message: 'Job is already running' }, 409);
return;
}
// fire and forget; actual execution handled by index.js listener
bus.emit('jobs:runOne', { jobId });
res.send({ message: 'Job run accepted' }, 202);
} catch (error) {
logger.error(error);
res.send({ message: 'Unexpected error triggering job' }, 500);
}
});
jobRouter.post('/', async (req, res) => { fastify.post('/:jobId/run', async (request, reply) => {
const { const { jobId } = request.params;
provider, try {
notificationAdapter, const job = jobStorage.getJob(jobId);
name, if (!job) {
blacklist = [], return reply.code(404).send({ message: 'Job not found' });
jobId, }
enabled, if (!doesJobBelongsToUser(job, request)) {
shareWithUsers = [], return reply.code(403).send({ message: 'You are trying to run a job that is not associated to your user' });
spatialFilter = null, }
specFilter = null, if (isJobRunning(jobId)) {
} = req.body; return reply.code(409).send({ message: 'Job is already running' });
const settings = await getSettings(); }
try { bus.emit('jobs:runOne', { jobId });
let jobFromDb = jobStorage.getJob(jobId); return reply.code(202).send({ message: 'Job run accepted' });
} catch (error) {
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) { logger.error(error);
res.send(new Error('You are trying to change a job that is not associated to your user.')); return reply.code(500).send({ message: 'Unexpected error triggering job' });
return;
} }
});
if (settings.demoMode && !isAdmin(req) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) { fastify.post('/', async (request, reply) => {
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)')); const {
return;
}
jobStorage.upsertJob({
userId: req.session.currentUser,
jobId,
enabled,
name,
blacklist,
provider, provider,
notificationAdapter, notificationAdapter,
shareWithUsers, name,
spatialFilter, blacklist = [],
specFilter, jobId,
}); enabled,
} catch (error) { shareWithUsers = [],
res.send(new Error(error)); spatialFilter = null,
logger.error(error); specFilter = null,
} } = request.body;
res.send(); const settings = await getSettings();
}); try {
const jobFromDb = jobStorage.getJob(jobId);
jobRouter.delete('', async (req, res) => { if (jobFromDb && !doesJobBelongsToUser(jobFromDb, request)) {
const { jobId } = req.body; return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user.' });
const settings = await getSettings(); }
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
return;
}
if (!doesJobBelongsToUser(job, req)) { if (settings.demoMode && !isAdmin(request) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
res.send(new Error('You are trying to remove a job that is not associated to your user')); return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
} else { }
jobStorage.removeJob(jobId);
}
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
jobRouter.put('/:jobId/status', async (req, res) => {
const { status } = req.body;
const { jobId } = req.params;
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) { jobStorage.upsertJob({
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)')); userId: request.session.currentUser,
return;
}
if (!doesJobBelongsToUser(job, req)) {
res.send(new Error('You are trying change a job that is not associated to your user'));
} else {
jobStorage.setJobStatus({
jobId, jobId,
status, enabled,
name,
blacklist,
provider,
notificationAdapter,
shareWithUsers,
spatialFilter,
specFilter,
}); });
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
} }
} catch (error) { return reply.send();
res.send(new Error(error)); });
logger.error(error);
}
res.send();
});
jobRouter.get('/shareableUserList', async (req, res) => { fastify.delete('/', async (request, reply) => {
const currentUser = req.session.currentUser; const { jobId } = request.body;
const users = userStorage.getUsers(false); const settings = await getSettings();
res.body = users try {
.filter((user) => !user.isAdmin && user.id !== currentUser) const job = jobStorage.getJob(jobId);
.map((user) => ({ if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
id: user.id, return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
name: user.username, }
}));
res.send(); if (!doesJobBelongsToUser(job, request)) {
}); return reply.code(403).send({ error: 'You are trying to remove a job that is not associated to your user' });
export { jobRouter }; }
jobStorage.removeJob(jobId);
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
fastify.put('/:jobId/status', async (request, reply) => {
const { status } = request.body;
const { jobId } = request.params;
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
}
if (!doesJobBelongsToUser(job, request)) {
return reply.code(403).send({ error: 'You are trying change a job that is not associated to your user' });
}
jobStorage.setJobStatus({ jobId, status });
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
fastify.get('/shareableUserList', async (request) => {
const currentUser = request.session.currentUser;
const users = userStorage.getUsers(false);
return users
.filter((user) => !user.isAdmin && user.id !== currentUser)
.map((user) => ({
id: user.id,
name: user.username,
}));
});
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js'; import * as listingStorage from '../../services/storage/listingsStorage.js';
import * as watchListStorage from '../../services/storage/watchListStorage.js'; import * as watchListStorage from '../../services/storage/watchListStorage.js';
import { isAdmin as isAdminFn } from '../security.js'; import { isAdmin as isAdminFn } from '../security.js';
@@ -12,128 +11,114 @@ import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js'; import { getJobs } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana(); /**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function listingsPlugin(fastify) {
fastify.get('/table', async (request) => {
const {
page,
pageSize = 50,
activityFilter,
jobNameFilter,
providerFilter,
watchListFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = request.query || {};
const listingsRouter = service.newRouter(); const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter);
listingsRouter.get('/table', async (req, res) => { let jobFilter = null;
const { let jobIdFilter = null;
page, const jobs = getJobs();
pageSize = 50, if (!nullOrEmpty(jobNameFilter)) {
activityFilter, const job = jobs.find((j) => j.id === jobNameFilter);
jobNameFilter, jobFilter = job != null ? job.name : null;
providerFilter, jobIdFilter = job != null ? job.id : null;
watchListFilter, }
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = req.query || {};
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false) return listingStorage.queryListings({
const toBool = (v) => { page: page ? parseInt(page, 10) : 1,
if (v === true || v === 'true' || v === 1 || v === '1') return true; pageSize: pageSize ? parseInt(pageSize, 10) : 50,
if (v === false || v === 'false' || v === 0 || v === '0') return false; freeTextFilter: freeTextFilter || null,
return null; activityFilter: normalizedActivity,
}; jobNameFilter: jobFilter,
const normalizedActivity = toBool(activityFilter); jobIdFilter: jobIdFilter,
const normalizedWatch = toBool(watchListFilter); providerFilter,
watchListFilter: normalizedWatch,
let jobFilter = null; sortField: sortfield || null,
let jobIdFilter = null; sortDir: sortdir === 'desc' ? 'desc' : 'asc',
const jobs = getJobs(); userId: request.session.currentUser,
if (!nullOrEmpty(jobNameFilter)) { isAdmin: isAdminFn(request),
const job = jobs.find((j) => j.id === jobNameFilter); });
jobFilter = job != null ? job.name : null;
jobIdFilter = job != null ? job.id : null;
}
res.body = listingStorage.queryListings({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
freeTextFilter: freeTextFilter || null,
activityFilter: normalizedActivity,
jobNameFilter: jobFilter,
jobIdFilter: jobIdFilter,
providerFilter,
watchListFilter: normalizedWatch,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser,
isAdmin: isAdminFn(req),
}); });
res.send();
});
listingsRouter.get('/map', async (req, res) => { fastify.get('/map', async (request) => {
const { jobId } = req.query || {}; const { jobId } = request.query || {};
return listingStorage.getListingsForMap({
res.body = listingStorage.getListingsForMap({ jobId: nullOrEmpty(jobId) ? null : jobId,
jobId: nullOrEmpty(jobId) ? null : jobId, userId: request.session.currentUser,
userId: req.session.currentUser, isAdmin: isAdminFn(request),
isAdmin: isAdminFn(req), });
}); });
res.send();
});
listingsRouter.get('/:listingId', async (req, res) => { fastify.get('/:listingId', async (request, reply) => {
const { listingId } = req.params; const { listingId } = request.params;
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req)); const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
if (!listing) { if (!listing) {
res.statusCode = 404; return reply.code(404).send({ message: 'Listing not found' });
res.body = { message: 'Listing not found' };
return res.send();
}
res.body = listing;
res.send();
});
// Toggle watch state for the current user on a listing
listingsRouter.post('/watch', async (req, res) => {
try {
const { listingId } = req.body || {};
const userId = req.session?.currentUser;
if (!listingId || !userId) {
res.statusCode = 400;
res.body = { message: 'listingId or user not provided' };
return res.send();
} }
watchListStorage.toggleWatch(listingId, userId); return listing;
} catch (error) { });
logger.error(error);
res.statusCode = 500;
res.body = { message: 'Failed to toggle watch' };
}
res.send();
});
listingsRouter.delete('/job', async (req, res) => { fastify.post('/watch', async (request, reply) => {
const { jobId, hardDelete = false } = req.body; try {
const settings = await getSettings(); const { listingId } = request.body || {};
try { const userId = request.session?.currentUser;
if (settings.demoMode && !isAdminFn(req)) { if (!listingId || !userId) {
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)')); return reply.code(400).send({ message: 'listingId or user not provided' });
return; }
watchListStorage.toggleWatch(listingId, userId);
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to toggle watch' });
} }
return reply.send();
});
listingStorage.deleteListingsByJobId(jobId, hardDelete); fastify.delete('/job', async (request, reply) => {
} catch (error) { const { jobId, hardDelete = false } = request.body;
res.send(new Error(error)); const settings = await getSettings();
logger.error(error); try {
} if (settings.demoMode && !isAdminFn(request)) {
res.send(); return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
}); }
listingStorage.deleteListingsByJobId(jobId, hardDelete);
listingsRouter.delete('/', async (req, res) => { } catch (error) {
const { ids, hardDelete = false } = req.body; logger.error(error);
try { return reply.code(500).send({ error: error.message });
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete);
} }
} catch (error) { return reply.send();
res.send(new Error(error)); });
logger.error(error);
}
res.send();
});
export { listingsRouter }; fastify.delete('/', async (request, reply) => {
const { ids, hardDelete = false } = request.body;
try {
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js'; import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js'; import * as hasher from '../../services/security/hash.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
@@ -11,12 +10,12 @@ import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const MAX_LOGIN_ATTEMPTS = 10; const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const loginAttempts = new Map(); // ip -> { count, firstAttempt } const loginAttempts = new Map();
function getClientIp(req) { function getClientIp(request) {
const forwarded = req.headers['x-forwarded-for']; const forwarded = request.headers['x-forwarded-for'];
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown'; return (forwarded ? forwarded.split(',')[0] : request.socket?.remoteAddress) || 'unknown';
} }
function isRateLimited(ip) { function isRateLimited(ip) {
@@ -30,53 +29,51 @@ function isRateLimited(ip) {
return record.count > MAX_LOGIN_ATTEMPTS; return record.count > MAX_LOGIN_ATTEMPTS;
} }
const service = restana(); /**
const loginRouter = service.newRouter(); * @param {import('fastify').FastifyInstance} fastify
loginRouter.get('/user', async (req, res) => { */
const currentUserId = req.session.currentUser; export default async function loginPlugin(fastify) {
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId); fastify.get('/user', async (request) => {
if (currentUser == null) { const currentUserId = request.session?.currentUser;
res.body = {}; const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
} else { if (currentUser == null) {
res.body = { return {};
}
return {
userId: currentUser.id, userId: currentUser.id,
isAdmin: currentUser.isAdmin, isAdmin: currentUser.isAdmin,
}; };
} });
res.send();
});
loginRouter.post('/', async (req, res) => {
const ip = getClientIp(req);
if (isRateLimited(ip)) {
logger.error(`Login rate limit exceeded for IP ${ip}`);
res.send(429);
return;
}
const settings = await getSettings();
const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username);
if (user == null) {
res.send(401);
return;
}
if (user.password === hasher.hash(password)) {
if (settings.demoMode) {
await trackDemoAccessed();
}
req.session.currentUser = user.id; fastify.post('/', async (request, reply) => {
req.session.createdAt = Date.now(); const ip = getClientIp(request);
loginAttempts.delete(ip); if (isRateLimited(ip)) {
userStorage.setLastLoginToNow({ userId: user.id }); logger.error(`Login rate limit exceeded for IP ${ip}`);
res.send(200); return reply.code(429).send();
return; }
} else { const settings = await getSettings();
logger.error(`User ${username} tried to login, but password was wrong.`); const { username, password } = request.body;
} const user = userStorage.getUsers(true).find((u) => u.username === username);
res.send(401); if (user == null) {
}); return reply.code(401).send();
loginRouter.post('/logout', async (req, res) => { }
req.session = null; if (user.password === hasher.hash(password)) {
res.send(200); if (settings.demoMode) {
}); await trackDemoAccessed();
export { loginRouter }; }
request.session.currentUser = user.id;
request.session.createdAt = Date.now();
loginAttempts.delete(ip);
userStorage.setLastLoginToNow({ userId: user.id });
return reply.code(200).send();
} else {
logger.error(`User ${username} tried to login, but password was wrong.`);
}
return reply.code(401).send();
});
fastify.post('/logout', async (request, reply) => {
await request.session.destroy();
return reply.code(200).send();
});
}

View File

@@ -4,62 +4,64 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import restana from 'restana';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
const service = restana();
const notificationAdapterRouter = service.newRouter();
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js')); const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
const notificationAdapter = await Promise.all( const notificationAdapter = await Promise.all(
notificationAdapterList.map(async (pro) => { notificationAdapterList.map(async (pro) => {
return await import(`../../notification/adapter/${pro}`); return await import(`../../notification/adapter/${pro}`);
}), }),
); );
notificationAdapterRouter.post('/try', async (req, res) => {
const { id, fields } = req.body; /**
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id); * @param {import('fastify').FastifyInstance} fastify
if (adapter == null) { */
res.send(404); export default async function notificationAdapterPlugin(fastify) {
} fastify.get('/', async () => {
const notificationConfig = []; return notificationAdapter.map((adapter) => adapter.config);
const notificationObject = {};
Object.keys(fields).forEach((key) => {
notificationObject[key] = fields[key].value;
}); });
notificationConfig.push({
fields: { ...notificationObject }, fastify.post('/try', async (request, reply) => {
enabled: true, const { id, fields } = request.body;
id, const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
}); if (adapter == null) {
try { return reply.code(404).send();
await adapter.send({ }
serviceName: 'TestCall', const notificationConfig = [];
newListings: [ const notificationObject = {};
{ Object.keys(fields).forEach((key) => {
address: 'Heidestrasse 17, 51147 Köln', notificationObject[key] = fields[key].value;
description: exampleDescription,
id: '1',
imageUrl: 'https://placehold.co/600x400/png',
price: '1.000 €',
size: '76 m²',
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
url: 'https://www.orange-coding.net',
},
],
notificationConfig,
jobKey: 'TestJob',
}); });
res.send(); notificationConfig.push({
} catch (Exception) { fields: { ...notificationObject },
logger.error('Error during notification adapter test:', Exception); enabled: true,
res.send(new Error(Exception)); id,
} });
}); try {
notificationAdapterRouter.get('/', async (req, res) => { await adapter.send({
res.body = notificationAdapter.map((adapter) => adapter.config); serviceName: 'TestCall',
res.send(); newListings: [
}); {
export { notificationAdapterRouter }; address: 'Heidestrasse 17, 51147 Köln',
description: exampleDescription,
id: '1',
imageUrl: 'https://placehold.co/600x400/png',
price: '1.000 €',
size: '76 m²',
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
url: 'https://www.orange-coding.net',
},
],
notificationConfig,
jobKey: 'TestJob',
});
return reply.send();
} catch (Exception) {
logger.error('Error during notification adapter test:', Exception);
return reply.code(500).send({ error: String(Exception) });
}
});
}
const exampleDescription = ` const exampleDescription = `
Wohnungstyp: Etagenwohnung Wohnungstyp: Etagenwohnung
@@ -94,7 +96,7 @@ Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
Ausstattung: Ausstattung:
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche - neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
- sonniger Balkon (Süd) - sonniger Balkon (Süd)
- Tiefgaragenstellplatz - Tiefgaragenstellplatz
- Kellerabteil - Kellerabteil
- gepflegtes Mehrfamilienhaus - gepflegtes Mehrfamilienhaus
@@ -104,7 +106,7 @@ Vermietung direkt vom Eigentümer - provisionsfrei!
Lage: Lage:
• Park: 1 Minute zu Fuß • Park: 1 Minute zu Fuß
• S-Bahn Station: 2 Minuten zu Fuß • S-Bahn Station: 2 Minuten zu Fuß
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe • Supermärkte, Restaurants, täglicher Bedarf in der Nähe
• Gute Anbindung Richtung Großstadt und Flughafen • Gute Anbindung Richtung Großstadt und Flughafen
`; `;

View File

@@ -4,17 +4,15 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import restana from 'restana';
const service = restana();
const providerRouter = service.newRouter();
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js')); const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
const provider = await Promise.all( const providers = await Promise.all(providerList.map(async (pro) => import(`../../provider/${pro}`)));
providerList.map(async (pro) => {
return await import(`../../provider/${pro}`); /**
}), * @param {import('fastify').FastifyInstance} fastify
); */
providerRouter.get('/', async (req, res) => { export default async function providerPlugin(fastify) {
res.body = provider.map((p) => p.metaInformation); fastify.get('/', async () => {
res.send(); return providers.map((p) => p.metaInformation);
}); });
export { providerRouter }; }

View File

@@ -3,35 +3,29 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { trackPoi } from '../../services/tracking/Tracker.js'; import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
const service = restana(); /**
const trackingRouter = service.newRouter(); * @param {import('fastify').FastifyInstance} fastify
*/
export default async function trackingPlugin(fastify) {
fastify.get('/trackingPois', async () => {
return TRACKING_POIS;
});
trackingRouter.get('/trackingPois', async (req, res) => { fastify.post('/poi', async (request, reply) => {
res.body = TRACKING_POIS; const { poi } = request.body;
res.send(); if (!poi) {
}); return reply.code(400).send({ error: 'Feature name is required' });
}
trackingRouter.post('/poi', async (req, res) => { try {
const { poi } = req.body; await trackPoi(poi);
if (!poi) { return { success: true };
res.statusCode = 400; } catch (error) {
res.send({ error: 'Feature name is required' }); logger.error('Error tracking feature', error);
return; return reply.code(500).send({ error: error.message });
} }
});
try { }
await trackPoi(poi);
res.send({ success: true });
} catch (error) {
logger.error('Error tracking feature', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
export { trackingRouter };

View File

@@ -3,82 +3,73 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js'; import * as userStorage from '../../services/storage/userStorage.js';
import * as jobStorage from '../../services/storage/jobStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin as isAdminUser } from '../security.js'; import { isAdmin as isAdminUser } from '../security.js';
const service = restana();
const userRouter = service.newRouter();
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) { function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0; return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
} }
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
return req.session.currentUser === userIdToBeRemoved; function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, request) {
return request.session.currentUser === userIdToBeRemoved;
} }
const nullOrEmpty = (str) => str == null || str.length === 0; const nullOrEmpty = (str) => str == null || str.length === 0;
userRouter.get('/', async (req, res) => { /**
res.body = userStorage.getUsers(false); * @param {import('fastify').FastifyInstance} fastify
res.send(); */
}); export default async function userPlugin(fastify) {
fastify.get('/', async () => {
userRouter.get('/:userId', async (req, res) => { return userStorage.getUsers(false);
const { userId } = req.params;
res.body = userStorage.getUser(userId);
res.send();
});
userRouter.delete('/', async (req, res) => {
const settings = await getSettings();
if (settings.demoMode && !isAdminUser(req)) {
res.send(new Error('In demo mode, it is not allowed to remove user.'));
return;
}
const { userId } = req.body;
const allUser = userStorage.getUsers(false);
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send(new Error('You are trying to remove the last admin user. This is prohibited.'));
return;
}
if (checkIfUserToBeRemovedIsLoggedIn(userId, req)) {
res.send(new Error('You are trying to remove yourself. This is prohibited.'));
return;
}
//TODO: Remove also analytics
jobStorage.removeJobsByUserId(userId);
userStorage.removeUser(userId);
res.send();
});
userRouter.post('/', async (req, res) => {
const settings = await getSettings();
if (settings.demoMode && !isAdminUser(req)) {
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
return;
}
const { username, password, password2, isAdmin, userId } = req.body;
if (password !== password2) {
res.send(new Error('Passwords does not match'));
return;
}
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
res.send(new Error('Username and password are mandatory.'));
return;
}
const allUser = userStorage.getUsers(false);
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send(
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'),
);
return;
}
userStorage.upsertUser({
userId,
username,
password,
isAdmin,
}); });
res.send();
}); fastify.get('/:userId', async (request) => {
export { userRouter }; const { userId } = request.params;
return userStorage.getUser(userId);
});
fastify.delete('/', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdminUser(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to remove user.' });
}
const { userId } = request.body;
const allUser = userStorage.getUsers(false);
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
return reply.code(400).send({ error: 'You are trying to remove the last admin user. This is prohibited.' });
}
if (checkIfUserToBeRemovedIsLoggedIn(userId, request)) {
return reply.code(400).send({ error: 'You are trying to remove yourself. This is prohibited.' });
}
jobStorage.removeJobsByUserId(userId);
userStorage.removeUser(userId);
return reply.send();
});
fastify.post('/', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdminUser(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change or add user.' });
}
const { username, password, password2, isAdmin, userId } = request.body;
if (password !== password2) {
return reply.code(400).send({ error: 'Passwords does not match' });
}
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
return reply.code(400).send({ error: 'Username and password are mandatory.' });
}
const allUser = userStorage.getUsers(false);
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
return reply.code(400).send({
error: 'You cannot change the admin flag for this user as otherwise, there is no other user in the system',
});
}
userStorage.upsertUser({ userId, username, password, isAdmin });
return reply.send();
});
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import SqliteConnection from '../../services/storage/SqliteConnection.js'; import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js'; import { isAdmin } from '../security.js';
@@ -16,113 +15,98 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js'; import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
const service = restana(); /**
const userSettingsRouter = service.newRouter(); * @param {import('fastify').FastifyInstance} fastify
*/
userSettingsRouter.get('/', async (req, res) => { export default async function userSettingsPlugin(fastify) {
const userId = req.session.currentUser; fastify.get('/', async (request) => {
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId }); const userId = request.session.currentUser;
const settings = {}; const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
for (const r of rows) { const settings = {};
settings[r.name] = fromJson(r.value, null); for (const r of rows) {
} settings[r.name] = fromJson(r.value, null);
res.body = settings;
res.send();
});
userSettingsRouter.get('/autocomplete', async (req, res) => {
const { q } = req.query;
try {
const results = await autocompleteAddress(q);
res.body = results;
res.send();
} catch (error) {
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/home-address', async (req, res) => {
const userId = req.session.currentUser;
const { home_address } = req.body;
const settings = await getSettings();
if (settings.demoMode && !isAdmin(req)) {
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
return;
}
try {
if (home_address) {
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
resetGeocoordinatesAndDistanceForUser(userId);
//we do NOT wait for this to finish, as we don't want to block the response
runGeoCordTask();
res.send({ success: true, coords });
} else {
res.statusCode = 400;
res.send({ error: 'Could not geocode address' });
}
} else {
upsertSettings({ home_address: null }, userId);
res.send({ success: true });
} }
} catch (error) { return settings;
logger.error('Error updating home address settings', error); });
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/news-hash', async (req, res) => { fastify.get('/autocomplete', async (request, reply) => {
const userId = req.session.currentUser; const { q } = request.query;
const { news_hash } = req.body; try {
const results = await autocompleteAddress(q);
return results;
} catch (error) {
return reply.code(500).send({ error: error.message });
}
});
const globalSettings = await getSettings(); fastify.post('/home-address', async (request, reply) => {
if (globalSettings.demoMode && !isAdmin(req)) { const userId = request.session.currentUser;
res.statusCode = 403; const { home_address } = request.body;
res.send({ error: 'In demo mode, it is not allowed to change settings.' }); const settings = await getSettings();
return;
}
try { if (settings.demoMode && !isAdmin(request)) {
upsertSettings({ news_hash }, userId); return reply.code(403).send({ error: 'In demo mode, it is not allowed to change the home address.' });
res.send({ success: true }); }
} catch (error) {
logger.error('Error updating news hash', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/provider-details', async (req, res) => { try {
const userId = req.session.currentUser; if (home_address) {
const { provider_details } = req.body; await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
resetGeocoordinatesAndDistanceForUser(userId);
runGeoCordTask();
return { success: true, coords };
} else {
return reply.code(400).send({ error: 'Could not geocode address' });
}
} else {
upsertSettings({ home_address: null }, userId);
return { success: true };
}
} catch (error) {
logger.error('Error updating home address settings', error);
return reply.code(500).send({ error: error.message });
}
});
const globalSettings = await getSettings(); fastify.post('/news-hash', async (request, reply) => {
if (globalSettings.demoMode && !isAdmin(req)) { const userId = request.session.currentUser;
res.statusCode = 403; const { news_hash } = request.body;
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
return;
}
if (!Array.isArray(provider_details)) { const globalSettings = await getSettings();
res.statusCode = 400; if (globalSettings.demoMode && !isAdmin(request)) {
res.send({ error: 'provider_details must be an array of provider ids.' }); return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
return; }
}
try { try {
upsertSettings({ provider_details }, userId); upsertSettings({ news_hash }, userId);
res.send({ success: true }); return { success: true };
} catch (error) { } catch (error) {
logger.error('Error updating provider details setting', error); logger.error('Error updating news hash', error);
res.statusCode = 500; return reply.code(500).send({ error: error.message });
res.send({ error: error.message }); }
} });
});
export { userSettingsRouter }; fastify.post('/provider-details', async (request, reply) => {
const userId = request.session.currentUser;
const { provider_details } = request.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
}
if (!Array.isArray(provider_details)) {
return reply.code(400).send({ error: 'provider_details must be an array of provider ids.' });
}
try {
upsertSettings({ provider_details }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating provider details setting', error);
return reply.code(500).send({ error: error.message });
}
});
}

View File

@@ -3,27 +3,10 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { getPackageVersion } from '../../utils.js'; import { getPackageVersion } from '../../utils.js';
import semver from 'semver'; import semver from 'semver';
const service = restana();
const versionRouter = service.newRouter();
versionRouter.get('/', async (req, res) => {
const versionPayload = await getCurrentVersionFromGithub();
const localFredyVersion = await getPackageVersion();
res.body =
versionPayload == null
? {
newVersion: false,
localFredyVersion,
}
: versionPayload;
res.send();
});
async function getCurrentVersionFromGithub() { async function getCurrentVersionFromGithub() {
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest'); const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
const data = await raw.json(); const data = await raw.json();
@@ -40,4 +23,13 @@ async function getCurrentVersionFromGithub() {
}; };
} }
export { versionRouter }; /**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function versionPlugin(fastify) {
fastify.get('/', async () => {
const versionPayload = await getCurrentVersionFromGithub();
const localFredyVersion = await getPackageVersion();
return versionPayload ?? { newVersion: false, localFredyVersion };
});
}

View File

@@ -4,53 +4,50 @@
*/ */
import * as userStorage from '../services/storage/userStorage.js'; import * as userStorage from '../services/storage/userStorage.js';
import cookieSession from 'cookie-session';
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
const unauthorized = (res) => {
return res.send(401); /**
}; * Returns true when the request has no valid, non-expired session.
const isUnauthorized = (req) => { * @param {import('fastify').FastifyRequest} request
if (req.session.currentUser == null) return true; * @returns {boolean}
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) { */
req.session = null; export function isUnauthorized(request) {
return true; if (!request.session?.currentUser) return true;
} if (Date.now() - (request.session.createdAt || 0) > SESSION_MAX_AGE) return true;
return false; return false;
}; }
const isAdmin = (req) => {
if (!isUnauthorized(req)) { /**
const user = userStorage.getUser(req.session.currentUser); * Returns true when the session belongs to an admin user.
return user != null && user.isAdmin; * @param {import('fastify').FastifyRequest} request
* @returns {boolean}
*/
export function isAdmin(request) {
if (isUnauthorized(request)) return false;
const user = userStorage.getUser(request.session.currentUser);
return user != null && user.isAdmin;
}
/**
* Fastify preHandler hook - rejects unauthenticated requests with 401.
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
*/
export async function authHook(request, reply) {
if (isUnauthorized(request)) {
reply.code(401).send();
} }
return false; }
};
const authInterceptor = () => { /**
return (req, res, next) => { * Fastify preHandler hook - rejects non-admin requests with 401.
if (isUnauthorized(req)) { * Apply after authHook.
return unauthorized(res); * @param {import('fastify').FastifyRequest} request
} else { * @param {import('fastify').FastifyReply} reply
next(); */
} export async function adminHook(request, reply) {
}; if (!isAdmin(request)) {
}; reply.code(401).send();
const adminInterceptor = () => { }
return (req, res, next) => { }
if (!isAdmin(req)) {
return unauthorized(res);
} else {
next();
}
};
};
const cookieSession$0 = (secret) => {
return cookieSession({
name: 'fredy-admin-session',
keys: [secret],
maxAge: SESSION_MAX_AGE,
});
};
export { cookieSession$0 as cookieSession };
export { adminInterceptor };
export { authInterceptor };
export { isUnauthorized };
export { isAdmin };

View File

@@ -133,7 +133,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
#### Setup #### Setup
1. Open **Claude Desktop** 1. Open **Claude Desktop**
2. Go to **Settings → Developer → Edit Config** this opens the `claude_desktop_config.json` file 2. Go to **Settings → Developer → Edit Config** - this opens the `claude_desktop_config.json` file
3. Add the `fredy` server to the `mcpServers` object: 3. Add the `fredy` server to the `mcpServers` object:
```json ```json
@@ -158,7 +158,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node` > - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
4. Save the file and **restart Claude Desktop** 4. Save the file and **restart Claude Desktop**
5. You should see a hammer icon (🔨) in the chat input click it to confirm the Fredy tools are listed 5. You should see a hammer icon (🔨) in the chat input - click it to confirm the Fredy tools are listed
#### Usage #### Usage
@@ -170,7 +170,7 @@ Once connected, simply ask Claude about your real estate data:
Claude will automatically call the appropriate Fredy MCP tools. Claude will automatically call the appropriate Fredy MCP tools.
> **Note:** Fredy's main web process does not need to be running the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied. > **Note:** Fredy's main web process does not need to be running - the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
--- ---
@@ -252,7 +252,7 @@ Example list response:
``` ```
**Tool:** list_listings | **Status:** OK **Tool:** list_listings | **Status:** OK
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available use page=2 to continue. Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available - use page=2 to continue.
| ID | Title | Address | Price | Size | Provider | Active | Created | Job | | ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|----|-------|---------|-------|------|----------|--------|---------|-----| |----|-------|---------|-------|------|----------|--------|---------|-----|

View File

@@ -49,7 +49,7 @@ export function createMcpServer() {
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' + 'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
'and get_listing for full details of a single listing. ' + 'and get_listing for full details of a single listing. ' +
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' + 'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
'Always present results to the user as soon as you have them do NOT call the tool again unless you need additional pages or different data.', 'Always present results to the user as soon as you have them - do NOT call the tool again unless you need additional pages or different data.',
}, },
); );

View File

@@ -3,10 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './mcpAdapter.js'; import { createMcpServer } from './mcpAdapter.js';
import { authenticateRequest } from './mcpAuthentication.js'; import { authenticateRequest } from './mcpAuthentication.js';
@@ -15,16 +11,13 @@ import crypto from 'crypto';
/** /**
* Active transports keyed by session id. * Active transports keyed by session id.
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>} * @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
*/ */
const sessions = new Map(); const sessions = new Map();
/** /**
* Get or create a session for the given session id with authentication.
* @param {string|undefined} sessionId * @param {string|undefined} sessionId
* @param {{ userId: string }} auth * @param {{ userId: string }} auth
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
*/ */
function getOrCreateSession(sessionId, auth) { function getOrCreateSession(sessionId, auth) {
if (sessionId && sessions.has(sessionId)) { if (sessionId && sessions.has(sessionId)) {
@@ -54,77 +47,67 @@ function getOrCreateSession(sessionId, auth) {
} }
/** /**
* Register MCP Streamable HTTP routes on a restana service. * Register MCP Streamable HTTP routes on a fastify instance.
* *
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol: * POST /api/mcp JSON-RPC messages
* - POST /api/mcp JSON-RPC messages (initialize, tool calls, etc.) * GET /api/mcp SSE stream for server-initiated notifications
* - GET /api/mcp SSE stream for server-initiated notifications * DELETE /api/mcp session termination
* - DELETE /api/mcp session termination
* *
* All endpoints require a valid Bearer token in the Authorization header. * All endpoints require a valid Bearer token in the Authorization header.
* *
* @param {import('restana').Service} service - The restana service instance. * @param {import('fastify').FastifyInstance} fastify
*/ */
export function registerMcpRoutes(service) { export function registerMcpRoutes(fastify) {
// POST main JSON-RPC endpoint fastify.post('/api/mcp', async (request, reply) => {
service.post('/api/mcp', async (req, res) => { const auth = authenticateRequest(request.raw);
const auth = authenticateRequest(req);
if (!auth) { if (!auth) {
res.statusCode = 401; return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
} }
const sessionId = req.headers['mcp-session-id']; const sessionId = request.raw.headers['mcp-session-id'];
const { server, transport } = getOrCreateSession(sessionId, auth); const { server, transport } = getOrCreateSession(sessionId, auth);
// Connect server to transport if not already connected
if (!transport.onmessage) { if (!transport.onmessage) {
await server.connect(transport); await server.connect(transport);
} }
// Inject authInfo so tools can access the authenticated user request.raw.auth = { userId: auth.userId };
req.auth = { userId: auth.userId };
await transport.handleRequest(req, res, req.body); reply.hijack();
await transport.handleRequest(request.raw, reply.raw, request.body);
}); });
// GET SSE stream for server-initiated messages fastify.get('/api/mcp', async (request, reply) => {
service.get('/api/mcp', async (req, res) => { const auth = authenticateRequest(request.raw);
const auth = authenticateRequest(req);
if (!auth) { if (!auth) {
res.statusCode = 401; return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
} }
const sessionId = req.headers['mcp-session-id']; const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) { if (!sessionId || !sessions.has(sessionId)) {
res.statusCode = 400; return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
} }
const { transport } = sessions.get(sessionId); const { transport } = sessions.get(sessionId);
await transport.handleRequest(req, res); reply.hijack();
await transport.handleRequest(request.raw, reply.raw);
}); });
// DELETE terminate session fastify.delete('/api/mcp', async (request, reply) => {
service.delete('/api/mcp', async (req, res) => { const auth = authenticateRequest(request.raw);
const auth = authenticateRequest(req);
if (!auth) { if (!auth) {
res.statusCode = 401; return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
} }
const sessionId = req.headers['mcp-session-id']; const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) { if (!sessionId || !sessions.has(sessionId)) {
res.statusCode = 404; return reply.code(404).send({ error: 'Session not found.' });
return res.send({ error: 'Session not found.' });
} }
const { transport } = sessions.get(sessionId); const { transport } = sessions.get(sessionId);
await transport.close(); await transport.close();
sessions.delete(sessionId); sessions.delete(sessionId);
res.statusCode = 200; return { ok: true };
res.send({ ok: true });
}); });
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp'); logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');

View File

@@ -70,7 +70,7 @@ export function normalizeListJobs(queryResult, { page, pageSize }) {
let md = `**Tool:** list_jobs | **Status:** OK\n\n`; let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`; md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
if (hasMore) md += ` More pages available use page=${page + 1} to continue.`; if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
md += '\n\n'; md += '\n\n';
if (jobs.length > 0) { if (jobs.length > 0) {
@@ -120,7 +120,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
let md = `**Tool:** list_listings | **Status:** OK\n\n`; let md = `**Tool:** list_listings | **Status:** OK\n\n`;
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`; md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
if (hasMore) md += ` More pages available use page=${page + 1} to continue.`; if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
md += '\n\n'; md += '\n\n';
if (listings.length > 0) { if (listings.length > 0) {

View File

@@ -17,6 +17,6 @@ Multiple recipients:
Common SMTP settings: Common SMTP settings:
- **Gmail** `smtp.gmail.com`, port 587, secure: false - **Gmail** - `smtp.gmail.com`, port 587, secure: false
- **Outlook** `smtp.office365.com`, port 587, secure: false - **Outlook** - `smtp.office365.com`, port 587, secure: false
- **Yahoo** `smtp.mail.yahoo.com`, port 465, secure: true - **Yahoo** - `smtp.mail.yahoo.com`, port 465, secure: true

View File

@@ -94,7 +94,7 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver // webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// chrome runtime expose loadTimes, csi and app like real Chrome // chrome runtime - expose loadTimes, csi and app like real Chrome
// @ts-ignore // @ts-ignore
window.chrome = { window.chrome = {
runtime: {}, runtime: {},
@@ -129,7 +129,7 @@ export async function applyBotPreventionToPage(page, cfg) {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','), get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
}); });
// plugins mimic real Chrome's built-in PDF plugins // plugins - mimic real Chrome's built-in PDF plugins
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => { const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null }; const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
const plugin = { name, filename, description, length: 1, 0: mimeObj }; const plugin = { name, filename, description, length: 1, 0: mimeObj };
@@ -274,14 +274,14 @@ export async function applyBotPreventionToPage(page, cfg) {
//noop //noop
} }
// document.hasFocus headless returns false; real active tabs return true // document.hasFocus - headless returns false; real active tabs return true
try { try {
document.hasFocus = () => true; document.hasFocus = () => true;
} catch { } catch {
//noop //noop
} }
// screen color depth normalise in case headless reports 0 // screen color depth - normalise in case headless reports 0
try { try {
Object.defineProperty(screen, 'colorDepth', { get: () => 24 }); Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }); Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });

View File

@@ -47,7 +47,7 @@ export async function launchBrowser(url, options) {
removeUserDataDir = true; removeUserDataDir = true;
} }
// On ARM64 Docker, Chrome for Testing has no native binary use system Chromium instead. // On ARM64 Docker, Chrome for Testing has no native binary - use system Chromium instead.
const executablePath = const executablePath =
options?.executablePath || options?.executablePath ||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined); (process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "21.0.2", "version": "21.1.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
@@ -65,6 +65,10 @@
"@douyinfe/semi-icons": "^2.95.1", "@douyinfe/semi-icons": "^2.95.1",
"@douyinfe/semi-ui": "2.95.1", "@douyinfe/semi-ui": "2.95.1",
"@douyinfe/semi-ui-19": "^2.95.1", "@douyinfe/semi-ui-19": "^2.95.1",
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/session": "^11.1.1",
"@fastify/static": "^9.1.3",
"@mapbox/mapbox-gl-draw": "^1.5.1", "@mapbox/mapbox-gl-draw": "^1.5.1",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6", "@sendgrid/mail": "8.1.6",
@@ -72,17 +76,16 @@
"@vitejs/plugin-react": "6.0.1", "@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"body-parser": "2.2.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cookie-session": "2.1.1", "fastify": "^5.8.5",
"handlebars": "4.7.9", "handlebars": "4.7.9",
"maplibre-gl": "^5.24.0", "maplibre-gl": "^5.24.0",
"nanoid": "5.1.9", "nanoid": "5.1.9",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-mailjet": "6.0.11", "node-mailjet": "6.0.11",
"nodemailer": "^8.0.6", "nodemailer": "^8.0.7",
"p-throttle": "^8.1.0", "p-throttle": "^8.1.0",
"package-up": "^5.0.0", "package-up": "^5.0.0",
"puppeteer": "^24.42.0", "puppeteer": "^24.42.0",
@@ -96,9 +99,7 @@
"react-router": "7.14.2", "react-router": "7.14.2",
"react-router-dom": "7.14.2", "react-router-dom": "7.14.2",
"resend": "^6.12.2", "resend": "^6.12.2",
"restana": "5.2.0",
"semver": "^7.7.4", "semver": "^7.7.4",
"serve-static": "2.2.1",
"slack": "11.0.2", "slack": "11.0.2",
"vite": "8.0.10", "vite": "8.0.10",
"x-var": "^3.0.1", "x-var": "^3.0.1",

View File

@@ -78,7 +78,7 @@ describe('#immobilien.de testsuite()', () => {
expect(listing.link).toContain('https://www.immobilien.de'); expect(listing.link).toContain('https://www.immobilien.de');
expect(listing.address).toBeTypeOf('string'); expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe(''); expect(listing.address).not.toBe('');
// description may be null if selectors don't match yet falls back gracefully // description may be null if selectors don't match yet - falls back gracefully
if (listing.description != null) { if (listing.description != null) {
expect(listing.description).toBeTypeOf('string'); expect(listing.description).toBeTypeOf('string');
} }

View File

@@ -713,7 +713,7 @@
</div><!-- /srb-filters --> </div><!-- /srb-filters -->
<!-- Search button CMS handles via onclick="{{submit()}}"; srbDoSearch() is <!-- Search button - CMS handles via onclick="{{submit()}}"; srbDoSearch() is
a standalone fallback that skips empty params (used outside CMS context) --> a standalone fallback that skips empty params (used outside CMS context) -->
<button type="button" class="srb-search-btn" id="srbSearchBtn"> <button type="button" class="srb-search-btn" id="srbSearchBtn">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -740,7 +740,7 @@
</div><!-- /srb-wrap --> </div><!-- /srb-wrap -->
<!-- ═══════════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════════
MODAL WEITERE FILTER MODAL - WEITERE FILTER
═══════════════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════════════ -->
<!-- Screen-reader live region: announces filter changes and modal state --> <!-- Screen-reader live region: announces filter changes and modal state -->
<div id="srbLiveRegion" role="status" aria-live="polite" aria-atomic="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"></div> <div id="srbLiveRegion" role="status" aria-live="polite" aria-atomic="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"></div>
@@ -768,7 +768,7 @@
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"></circle> <circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"></circle>
</svg> </svg>
<input type="text" class="srb-modal-location-input" id="srbModalLocation" placeholder="Ort, Postleitzahl, Stadt" autocomplete="off" b-event="keyup" role="combobox" aria-autocomplete="list" aria-controls="srbModalLocationList" aria-expanded="false"> <input type="text" class="srb-modal-location-input" id="srbModalLocation" placeholder="Ort, Postleitzahl, Stadt" autocomplete="off" b-event="keyup" role="combobox" aria-autocomplete="list" aria-controls="srbModalLocationList" aria-expanded="false">
<!-- Mirror of the desktop grbWoHidden Piglet updates this via b-model <!-- Mirror of the desktop grbWoHidden - Piglet updates this via b-model
when select(entry) fires in the modal controller's own scope. --> when select(entry) fires in the modal controller's own scope. -->
<input type="hidden" id="srbModalWoHidden" value="district:2434,2695,2621,2700,2967,2734,2909,2955,2392,2746,2767,2982,2904,2612,2892,2587,2871,2975,2591,2887,2569,2640,2735"> <input type="hidden" id="srbModalWoHidden" value="district:2434,2695,2621,2700,2967,2734,2909,2955,2392,2746,2767,2982,2904,2612,2892,2587,2871,2975,2591,2887,2569,2640,2735">
<div b-redrawable="autocomplete" class="srb-autocomplete-wrapper"> <div b-redrawable="autocomplete" class="srb-autocomplete-wrapper">
@@ -899,7 +899,7 @@
</div> </div>
</div> </div>
<!-- ── Additional criteria CMS server-side rendered per typ+objektart ── --> <!-- ── Additional criteria - CMS server-side rendered per typ+objektart ── -->
<div class="srb-modal-criteria-groups"> <div class="srb-modal-criteria-groups">
<div class="srb-modal-section srb-modal-section--animated srb-modal-section--criteria" data-valid-searches="[&quot;kaufen_grundstueck&quot;,&quot;kaufen_haus&quot;,&quot;kaufen_rendite&quot;,&quot;kaufen_wohnung&quot;,&quot;mieten_grundstueck&quot;,&quot;mieten_haus&quot;,&quot;mieten_waz&quot;,&quot;mieten_wohnung&quot;]"> <div class="srb-modal-section srb-modal-section--animated srb-modal-section--criteria" data-valid-searches="[&quot;kaufen_grundstueck&quot;,&quot;kaufen_haus&quot;,&quot;kaufen_rendite&quot;,&quot;kaufen_wohnung&quot;,&quot;mieten_grundstueck&quot;,&quot;mieten_haus&quot;,&quot;mieten_waz&quot;,&quot;mieten_wohnung&quot;]">
<div class="srb-section-body"> <div class="srb-section-body">
@@ -4393,7 +4393,7 @@
void g.offsetHeight; void g.offsetHeight;
g.classList.remove('lr-card__gallery--no-transition'); g.classList.remove('lr-card__gallery--no-transition');
// counter total (CMS doesn't evaluate expressions in attrs read from hidden span) // counter total (CMS doesn't evaluate expressions in attrs - read from hidden span)
var totalEl = g.querySelector('.lr-card__gallery-total'); var totalEl = g.querySelector('.lr-card__gallery-total');
var counter = g.querySelector('.lr-card__gallery-counter'); var counter = g.querySelector('.lr-card__gallery-counter');
if (counter && totalEl) counter.dataset.total = totalEl.textContent.trim(); if (counter && totalEl) counter.dataset.total = totalEl.textContent.trim();

View File

@@ -1523,7 +1523,7 @@
<div class="hidden print:flex print:items-center print:justify-between mb-2"> <div class="hidden print:flex print:items-center print:justify-between mb-2">
<img src="/static/img/brand/logo.b187f1e54302.svg" class="w-32" alt="ohne-makler Immobilien selbst vermarkten"> <img src="/static/img/brand/logo.b187f1e54302.svg" class="w-32" alt="ohne-makler - Immobilien selbst vermarkten">
<br> <br>
Diese Seite wurde ausgedruckt von:<br> Diese Seite wurde ausgedruckt von:<br>
https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/ https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/

View File

@@ -12,7 +12,7 @@ import './Index.less';
// Semi UI uses react-dom (not react-dom/client) internally for imperative renders // Semi UI uses react-dom (not react-dom/client) internally for imperative renders
// like Toast, Notification, etc. In React 19, createRoot was removed from react-dom // like Toast, Notification, etc. In React 19, createRoot was removed from react-dom
// and lives only in react-dom/client inject it so Toast can create its own root. // and lives only in react-dom/client - inject it so Toast can create its own root.
semiGlobal.config.createRoot = createRoot; semiGlobal.config.createRoot = createRoot;
const container = document.getElementById('fredy'); const container = document.getElementById('fredy');

View File

@@ -71,7 +71,7 @@ body {
vertical-align: middle; vertical-align: middle;
} }
// Suppress focus outlines Semi uses --semi-color-primary (our red) for all rings // Suppress focus outlines - Semi uses --semi-color-primary (our red) for all rings
button:focus, button:focus,
button:focus-visible, button:focus-visible,
.semi-button:focus, .semi-button:focus,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

BIN
ui/src/assets/no_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -26,7 +26,7 @@ import {
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import no_image from '../../../assets/no_image.jpg'; import no_image from '../../../assets/no_image.png';
import * as timeService from '../../../services/time/timeService.js'; import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js'; import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import { useActions, useSelector } from '../../../services/state/store.js'; import { useActions, useSelector } from '../../../services/state/store.js';

View File

@@ -136,9 +136,9 @@
color: @color-muted !important; color: @color-muted !important;
} }
// Collapsed state icons perfectly centered // Collapsed state - icons perfectly centered
.semi-navigation-collapsed { .semi-navigation-collapsed {
// Text span is display:block and takes up flex space must be removed so justify-content:center works // Text span is display:block and takes up flex space - must be removed so justify-content:center works
.semi-navigation-item-text { .semi-navigation-item-text {
display: none !important; display: none !important;
} }
@@ -165,7 +165,7 @@
min-width: 0 !important; min-width: 0 !important;
} }
// Semi adds margin-right to icons for text spacing remove it when collapsed // Semi adds margin-right to icons for text spacing - remove it when collapsed
.semi-navigation-item-icon, .semi-navigation-item-icon,
.semi-navigation-sub-title-icon { .semi-navigation-sub-title-icon {
display: flex !important; display: flex !important;
@@ -179,13 +179,13 @@
} }
} }
// Semi Nav.Footer full width, no extra padding (our BEM class controls it) // Semi Nav.Footer - full width, no extra padding (our BEM class controls it)
.semi-navigation-footer { .semi-navigation-footer {
width: 100% !important; width: 100% !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }
// Collapsed submenu popup actual class used by Semi UI is .semi-navigation-popover // Collapsed submenu popup - actual class used by Semi UI is .semi-navigation-popover
.semi-navigation-popover { .semi-navigation-popover {
background: @color-elevated !important; background: @color-elevated !important;
border: 1px solid @color-border !important; border: 1px solid @color-border !important;

View File

@@ -32,7 +32,7 @@
padding: 16px 20px !important; padding: 16px 20px !important;
} }
// Semi input focus subtle, not accent // Semi input focus - subtle, not accent
.semi-input-wrapper:focus-within, .semi-input-wrapper:focus-within,
.semi-select:focus-within { .semi-select:focus-within {
border-color: @color-border-bright !important; border-color: @color-border-bright !important;

View File

@@ -299,7 +299,7 @@ const GeneralSettings = function GeneralSettings() {
<SegmentPart <SegmentPart
name="Analytics" name="Analytics"
helpText="Anonymous usage data to help improve Fredy provider names, adapter names, OS, Node version, and architecture." helpText="Anonymous usage data to help improve Fredy - provider names, adapter names, OS, Node version, and architecture."
> >
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}> <Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
Enable analytics Enable analytics

View File

@@ -43,7 +43,7 @@
} }
} }
// TimePicker fix scoped so it doesn't pollute modal headers // TimePicker fix - scoped so it doesn't pollute modal headers
.semi-timepicker .semi-input-wrapper, .semi-timepicker .semi-input-wrapper,
.semi-timepicker .semi-input-inset-label-wrapper { .semi-timepicker .semi-input-inset-label-wrapper {
background: @color-elevated !important; background: @color-elevated !important;

View File

@@ -36,7 +36,7 @@ import {
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js'; import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js'; import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost } from '../../services/xhr.js';
@@ -337,10 +337,16 @@ export default function ListingDetail() {
<Col span={24} lg={12}> <Col span={24} lg={12}>
<div className="listing-detail__image-container"> <div className="listing-detail__image-container">
<Image <Image
src={listing.image_url} src={listing.image_url ?? no_image}
fallback={no_image} fallback={
<img
src={no_image}
alt="No image available"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
preview={true} preview={!!listing.image_url}
/> />
</div> </div>
</Col> </Col>

View File

@@ -13,7 +13,7 @@ import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFro
import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19'; import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons'; import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.png';
import _RangeSlider from 'react-range-slider-input'; import _RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import './Map.less'; import './Map.less';

474
yarn.lock
View File

@@ -2,15 +2,6 @@
# yarn lockfile v1 # yarn lockfile v1
"0http@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/0http/-/0http-4.4.0.tgz#0929a272d32ac931dfab538be671c1520be5b194"
integrity sha512-Zs3pTtQFZL8ishQREXX+9cMo1zRgaFF8g6AtnEL0iPUhzi2thtHf3P+487uhIieJm+wyPMu5QHJi8PCrt2zXJQ==
dependencies:
lru-cache "^11.2.1"
regexparam "^3.0.0"
trouter "^4.0.0"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
version "7.29.0" version "7.29.0"
resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz"
@@ -1136,6 +1127,99 @@
"@eslint/core" "^1.2.1" "@eslint/core" "^1.2.1"
levn "^0.4.1" levn "^0.4.1"
"@fastify/accept-negotiator@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz#77afd6254ba77f6c22c6f35c4fb0c1b6d005199b"
integrity sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==
"@fastify/ajv-compiler@^4.0.5":
version "4.0.5"
resolved "https://registry.yarnpkg.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz#fdb0887a7af51abaae8c1829e8099d34f8ddd302"
integrity sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==
dependencies:
ajv "^8.12.0"
ajv-formats "^3.0.1"
fast-uri "^3.0.0"
"@fastify/cookie@^11.0.2":
version "11.0.2"
resolved "https://registry.yarnpkg.com/@fastify/cookie/-/cookie-11.0.2.tgz#fa772c652e51f9252addd788289aa16627ebe5f1"
integrity sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==
dependencies:
cookie "^1.0.0"
fastify-plugin "^5.0.0"
"@fastify/error@^4.0.0":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@fastify/error/-/error-4.2.0.tgz#d40f46ba75f541fdcc4dc276b7308bbc8e8e6d7a"
integrity sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==
"@fastify/fast-json-stringify-compiler@^5.0.0":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz#fae495bf30dbbd029139839ec5c2ea111bde7d3f"
integrity sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==
dependencies:
fast-json-stringify "^6.0.0"
"@fastify/forwarded@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@fastify/forwarded/-/forwarded-3.0.1.tgz#9662b7bd4a59f6d123cc3487494f75f635c32d23"
integrity sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==
"@fastify/helmet@^13.0.2":
version "13.0.2"
resolved "https://registry.yarnpkg.com/@fastify/helmet/-/helmet-13.0.2.tgz#c1b2e4dc28ddfb596121e311a0c9f318773b7f5b"
integrity sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==
dependencies:
fastify-plugin "^5.0.0"
helmet "^8.0.0"
"@fastify/merge-json-schemas@^0.2.0":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz#3aa30d2f0c81a8ac5995b6d94ed4eaa2c3055824"
integrity sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==
dependencies:
dequal "^2.0.3"
"@fastify/proxy-addr@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz#f5360b5dd83c7de3d41b415be4aab84ae44aa106"
integrity sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==
dependencies:
"@fastify/forwarded" "^3.0.0"
ipaddr.js "^2.1.0"
"@fastify/send@^4.0.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@fastify/send/-/send-4.1.0.tgz#d9c283b86e12080c0dcc160bbc16106debf1f0d3"
integrity sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==
dependencies:
"@lukeed/ms" "^2.0.2"
escape-html "~1.0.3"
fast-decode-uri-component "^1.0.1"
http-errors "^2.0.0"
mime "^3"
"@fastify/session@^11.1.1":
version "11.1.1"
resolved "https://registry.yarnpkg.com/@fastify/session/-/session-11.1.1.tgz#591071a567fcd86b96f49b4321dc0198e2f4e226"
integrity sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==
dependencies:
fastify-plugin "^5.0.1"
safe-stable-stringify "^2.4.3"
"@fastify/static@^9.1.3":
version "9.1.3"
resolved "https://registry.yarnpkg.com/@fastify/static/-/static-9.1.3.tgz#d878694333b75c646d40670b7416044de09f09ad"
integrity sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==
dependencies:
"@fastify/accept-negotiator" "^2.0.0"
"@fastify/send" "^4.0.0"
content-disposition "^1.0.1"
fastify-plugin "^5.0.0"
fastq "^1.17.1"
glob "^13.0.0"
"@floating-ui/core@^1.7.3": "@floating-ui/core@^1.7.3":
version "1.7.3" version "1.7.3"
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz" resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz"
@@ -1228,6 +1312,11 @@
resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz" resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz"
integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
"@lukeed/ms@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@lukeed/ms/-/ms-2.0.2.tgz#07f09e59a74c52f4d88c6db5c1054e819538e2a8"
integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==
"@mapbox/geojson-area@^0.2.2": "@mapbox/geojson-area@^0.2.2":
version "0.2.2" version "0.2.2"
resolved "https://registry.yarnpkg.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10" resolved "https://registry.yarnpkg.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10"
@@ -1418,6 +1507,11 @@
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663" resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663"
integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ== integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==
"@pinojs/redact@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@pinojs/redact/-/redact-0.4.0.tgz#c3de060dd12640dcc838516aa2a6803cc7b2e9d6"
integrity sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==
"@puppeteer/browsers@2.13.0": "@puppeteer/browsers@2.13.0":
version "2.13.0" version "2.13.0"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500" resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500"
@@ -2183,6 +2277,11 @@
convert-source-map "^2.0.0" convert-source-map "^2.0.0"
tinyrainbow "^3.1.0" tinyrainbow "^3.1.0"
abstract-logging@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839"
integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==
accepts@^2.0.0: accepts@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895"
@@ -2243,6 +2342,16 @@ ajv@^8.0.0, ajv@^8.17.1:
json-schema-traverse "^1.0.0" json-schema-traverse "^1.0.0"
require-from-string "^2.0.2" require-from-string "^2.0.2"
ajv@^8.12.0:
version "8.20.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9"
integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==
dependencies:
fast-deep-equal "^3.1.3"
fast-uri "^3.0.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
ansi-escapes@^7.0.0: ansi-escapes@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz"
@@ -2400,6 +2509,11 @@ asynckit@^0.4.0:
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
atomic-sleep@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b"
integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==
available-typed-arrays@^1.0.7: available-typed-arrays@^1.0.7:
version "1.0.7" version "1.0.7"
resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz" resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz"
@@ -2407,6 +2521,14 @@ available-typed-arrays@^1.0.7:
dependencies: dependencies:
possible-typed-array-names "^1.0.0" possible-typed-array-names "^1.0.0"
avvio@^9.0.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/avvio/-/avvio-9.2.0.tgz#16bb653c022237d1aeb984b00d3cbe2d96b77c20"
integrity sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==
dependencies:
"@fastify/error" "^4.0.0"
fastq "^1.17.1"
axios@^1.12.0: axios@^1.12.0:
version "1.13.1" version "1.13.1"
resolved "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz" resolved "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz"
@@ -2565,7 +2687,7 @@ bl@^4.0.3:
inherits "^2.0.4" inherits "^2.0.4"
readable-stream "^3.4.0" readable-stream "^3.4.0"
body-parser@2.2.2, body-parser@^2.2.1: body-parser@^2.2.1:
version "2.2.2" version "2.2.2"
resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz" resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz"
integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==
@@ -2600,6 +2722,13 @@ brace-expansion@^5.0.2:
dependencies: dependencies:
balanced-match "^4.0.2" balanced-match "^4.0.2"
brace-expansion@^5.0.5:
version "5.0.5"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb"
integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==
dependencies:
balanced-match "^4.0.2"
braces@~3.0.2: braces@~3.0.2:
version "3.0.3" version "3.0.3"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
@@ -2875,6 +3004,11 @@ content-disposition@^1.0.0:
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b"
integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==
content-disposition@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17"
integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==
content-type@^1.0.5: content-type@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz"
@@ -2885,16 +3019,6 @@ convert-source-map@^2.0.0:
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz"
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
cookie-session@2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz"
integrity sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==
dependencies:
cookies "0.9.1"
debug "3.2.7"
on-headers "~1.1.0"
safe-buffer "5.2.1"
cookie-signature@^1.2.1: cookie-signature@^1.2.1:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793"
@@ -2905,19 +3029,11 @@ cookie@^0.7.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
cookie@^1.0.1: cookie@^1.0.0, cookie@^1.0.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz" resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
cookies@0.9.1:
version "0.9.1"
resolved "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz"
integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==
dependencies:
depd "~2.0.0"
keygrip "~1.1.0"
copy-anything@^3.0.5: copy-anything@^3.0.5:
version "3.0.5" version "3.0.5"
resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0"
@@ -3039,13 +3155,6 @@ date-fns@^2.29.3:
dependencies: dependencies:
"@babel/runtime" "^7.21.0" "@babel/runtime" "^7.21.0"
debug@3.2.7:
version "3.2.7"
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
dependencies:
ms "^2.1.1"
debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.3: debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.3:
version "4.4.3" version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
@@ -3124,7 +3233,7 @@ depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
dequal@^2.0.0: dequal@^2.0.0, dequal@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
@@ -3441,9 +3550,9 @@ escalade@^3.1.1, escalade@^3.2.0:
resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
escape-html@^1.0.3: escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
escape-string-regexp@^4.0.0: escape-string-regexp@^4.0.0:
@@ -3763,6 +3872,11 @@ extract-zip@^2.0.1:
resolved "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz" resolved "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz"
integrity sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ== integrity sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==
fast-decode-uri-component@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543"
integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3" version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
@@ -3783,21 +3897,73 @@ fast-json-stable-stringify@^2.0.0:
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-json-stringify@^6.0.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz#e59f2fbd558842d7ec085276444d15e6500c16d4"
integrity sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==
dependencies:
"@fastify/merge-json-schemas" "^0.2.0"
ajv "^8.12.0"
ajv-formats "^3.0.1"
fast-uri "^3.0.0"
json-schema-ref-resolver "^3.0.0"
rfdc "^1.2.0"
fast-levenshtein@^2.0.6: fast-levenshtein@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fast-querystring@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/fast-querystring/-/fast-querystring-1.1.2.tgz#a6d24937b4fc6f791b4ee31dcb6f53aeafb89f53"
integrity sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==
dependencies:
fast-decode-uri-component "^1.0.1"
fast-sha256@^1.3.0: fast-sha256@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6" resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6"
integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ== integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==
fast-uri@^3.0.1: fast-uri@^3.0.0, fast-uri@^3.0.1:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
fastify-plugin@^5.0.0, fastify-plugin@^5.0.1:
version "5.1.0"
resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz#7083e039d6418415f9a669f8c25e72fc5bf2d3e7"
integrity sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==
fastify@^5.8.5:
version "5.8.5"
resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.8.5.tgz#c452224295e0ca550bcd0efc3f7d3e90e9c11955"
integrity sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==
dependencies:
"@fastify/ajv-compiler" "^4.0.5"
"@fastify/error" "^4.0.0"
"@fastify/fast-json-stringify-compiler" "^5.0.0"
"@fastify/proxy-addr" "^5.0.0"
abstract-logging "^2.0.1"
avvio "^9.0.0"
fast-json-stringify "^6.0.0"
find-my-way "^9.0.0"
light-my-request "^6.0.0"
pino "^9.14.0 || ^10.1.0"
process-warning "^5.0.0"
rfdc "^1.3.1"
secure-json-parse "^4.0.0"
semver "^7.6.0"
toad-cache "^3.7.0"
fastq@^1.17.1:
version "1.20.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==
dependencies:
reusify "^1.0.4"
fd-slicer@~1.1.0: fd-slicer@~1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz" resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz"
@@ -3854,6 +4020,15 @@ finalhandler@^2.1.0:
parseurl "^1.3.3" parseurl "^1.3.3"
statuses "^2.0.1" statuses "^2.0.1"
find-my-way@^9.0.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-9.5.0.tgz#3e6819bf4310b5293f490c032e70be0b506d0dc8"
integrity sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==
dependencies:
fast-deep-equal "^3.1.3"
fast-querystring "^1.0.0"
safe-regex2 "^5.0.0"
find-up-simple@^1.0.0: find-up-simple@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz" resolved "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz"
@@ -4076,6 +4251,15 @@ glob-parent@~5.1.2:
dependencies: dependencies:
is-glob "^4.0.1" is-glob "^4.0.1"
glob@^13.0.0:
version "13.0.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d"
integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==
dependencies:
minimatch "^10.2.2"
minipass "^7.1.3"
path-scurry "^2.0.2"
glob@^7.0.0, glob@^7.1.3: glob@^7.0.0, glob@^7.1.3:
version "7.2.3" version "7.2.3"
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
@@ -4216,6 +4400,11 @@ hast-util-whitespace@^3.0.0:
dependencies: dependencies:
"@types/hast" "^3.0.0" "@types/hast" "^3.0.0"
helmet@^8.0.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/helmet/-/helmet-8.1.0.tgz#f96d23fedc89e9476ecb5198181009c804b8b38c"
integrity sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==
history@5.3.0: history@5.3.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.npmjs.org/history/-/history-5.3.0.tgz" resolved "https://registry.npmjs.org/history/-/history-5.3.0.tgz"
@@ -4375,6 +4564,11 @@ ipaddr.js@1.9.1:
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
ipaddr.js@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz#71dce70e1398122208996d1c22f2ba46a24b1abc"
integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==
is-alphabetical@^2.0.0: is-alphabetical@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz" resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz"
@@ -4702,6 +4896,13 @@ json-parse-even-better-errors@^2.3.0:
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-ref-resolver@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz#28f6a410122cde9238762a5e9296faa38be28708"
integrity sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==
dependencies:
dequal "^2.0.3"
json-schema-traverse@^0.4.1: json-schema-traverse@^0.4.1:
version "0.4.1" version "0.4.1"
resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz"
@@ -4761,13 +4962,6 @@ kdbush@^4.0.2:
resolved "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz" resolved "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz"
integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==
keygrip@~1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz"
integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
dependencies:
tsscmp "1.0.6"
keyv@^4.5.4: keyv@^4.5.4:
version "4.5.4" version "4.5.4"
resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz"
@@ -4823,6 +5017,15 @@ levn@^0.4.1:
prelude-ls "^1.2.1" prelude-ls "^1.2.1"
type-check "~0.4.0" type-check "~0.4.0"
light-my-request@^6.0.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-6.6.0.tgz#c9448772323f65f33720fb5979c7841f14060add"
integrity sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==
dependencies:
cookie "^1.0.1"
process-warning "^4.0.0"
set-cookie-parser "^2.6.0"
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
version "1.32.0" version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
@@ -4983,7 +5186,7 @@ lottie-web@^5.13.0:
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.13.0.tgz#441d3df217cc8ba302338c3f168e1a3af0f221d3" resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.13.0.tgz#441d3df217cc8ba302338c3f168e1a3af0f221d3"
integrity sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ== integrity sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==
lru-cache@^11.2.1: lru-cache@^11.0.0:
version "11.3.5" version "11.3.5"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.5.tgz#29047d348c0b2793e3112a01c739bb7c6d855637" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.5.tgz#29047d348c0b2793e3112a01c739bb7c6d855637"
integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw== integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==
@@ -5690,6 +5893,11 @@ mime@^1.4.1:
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
mime@^3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
mimic-function@^5.0.0: mimic-function@^5.0.0:
version "5.0.1" version "5.0.1"
resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz" resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz"
@@ -5707,6 +5915,13 @@ minimatch@^10.2.1, minimatch@^10.2.4:
dependencies: dependencies:
brace-expansion "^5.0.2" brace-expansion "^5.0.2"
minimatch@^10.2.2:
version "10.2.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1"
integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
dependencies:
brace-expansion "^5.0.5"
minimatch@^3.1.1, minimatch@^3.1.2: minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2" version "3.1.2"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
@@ -5719,6 +5934,11 @@ minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.8:
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
minipass@^7.1.2, minipass@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
mitt@^3.0.1: mitt@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz" resolved "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz"
@@ -5737,7 +5957,7 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz" resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
ms@^2.1.1, ms@^2.1.3: ms@^2.1.3:
version "2.1.3" version "2.1.3"
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@@ -5835,7 +6055,7 @@ node-releases@^2.0.27:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
nodemailer@^8.0.6: nodemailer@^8.0.7:
version "8.0.7" version "8.0.7"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.7.tgz#538729a79444e538331bca8a6fc3e5c034eaebc6" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.7.tgz#538729a79444e538331bca8a6fc3e5c034eaebc6"
integrity sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow== integrity sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==
@@ -5930,6 +6150,11 @@ obug@^2.1.1:
resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be" resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be"
integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==
on-exit-leak-free@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8"
integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==
on-finished@^2.4.1: on-finished@^2.4.1:
version "2.4.1" version "2.4.1"
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
@@ -5937,11 +6162,6 @@ on-finished@^2.4.1:
dependencies: dependencies:
ee-first "1.1.1" ee-first "1.1.1"
on-headers@~1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz"
integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
once@^1.3.0, once@^1.3.1, once@^1.4.0: once@^1.3.0, once@^1.3.1, once@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
@@ -6112,6 +6332,14 @@ path-parse@^1.0.7:
resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-scurry@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85"
integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==
dependencies:
lru-cache "^11.0.0"
minipass "^7.1.2"
path-to-regexp@^8.0.0: path-to-regexp@^8.0.0:
version "8.3.0" version "8.3.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f"
@@ -6159,6 +6387,35 @@ pify@^4.0.1:
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz" resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pino-abstract-transport@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23"
integrity sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==
dependencies:
split2 "^4.0.0"
pino-std-serializers@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz#a7b0cd65225f29e92540e7853bd73b07479893fc"
integrity sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==
"pino@^9.14.0 || ^10.1.0":
version "10.3.1"
resolved "https://registry.yarnpkg.com/pino/-/pino-10.3.1.tgz#6552c8f8d8481844c9e452e7bf0be90bff1939ce"
integrity sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==
dependencies:
"@pinojs/redact" "^0.4.0"
atomic-sleep "^1.0.0"
on-exit-leak-free "^2.1.0"
pino-abstract-transport "^3.0.0"
pino-std-serializers "^7.0.0"
process-warning "^5.0.0"
quick-format-unescaped "^4.0.3"
real-require "^0.2.0"
safe-stable-stringify "^2.3.1"
sonic-boom "^4.0.1"
thread-stream "^4.0.0"
pkce-challenge@^5.0.0: pkce-challenge@^5.0.0:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d"
@@ -6237,6 +6494,16 @@ prismjs@^1.29.0:
resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz"
integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==
process-warning@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.1.tgz#5c1db66007c67c756e4e09eb170cdece15da32fb"
integrity sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==
process-warning@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7"
integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==
progress@^2.0.3: progress@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz" resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz"
@@ -6565,6 +6832,11 @@ query-string@9.3.1:
filter-obj "^5.1.0" filter-obj "^5.1.0"
split-on-first "^3.0.0" split-on-first "^3.0.0"
quick-format-unescaped@^4.0.3:
version "4.0.4"
resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7"
integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==
quickselect@^3.0.0: quickselect@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz" resolved "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz"
@@ -6680,6 +6952,11 @@ readdirp@~3.6.0:
dependencies: dependencies:
picomatch "^2.2.1" picomatch "^2.2.1"
real-require@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78"
integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==
rechoir@^0.6.2: rechoir@^0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz"
@@ -6765,11 +7042,6 @@ regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4:
gopd "^1.2.0" gopd "^1.2.0"
set-function-name "^2.0.2" set-function-name "^2.0.2"
regexparam@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz"
integrity sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==
regexpu-core@^6.3.1: regexpu-core@^6.3.1:
version "6.4.0" version "6.4.0"
resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz" resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz"
@@ -6901,13 +7173,6 @@ resolve@^2.0.0-next.5:
path-parse "^1.0.7" path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0" supports-preserve-symlinks-flag "^1.0.0"
restana@5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/restana/-/restana-5.2.0.tgz#71921e0fe48d6f59b2c212c5dd31524e157f7e8b"
integrity sha512-IY/ibi45o5fNLtC0lrYmLe0ncqknInePUBCt7Y8bzmhINp7ZxfGAB3p0YQv4JzZh7aYll0335lffOYlXpkjO/Q==
dependencies:
"0http" "^4.4.0"
restore-cursor@^5.0.0: restore-cursor@^5.0.0:
version "5.1.0" version "5.1.0"
resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz" resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz"
@@ -6916,9 +7181,19 @@ restore-cursor@^5.0.0:
onetime "^7.0.0" onetime "^7.0.0"
signal-exit "^4.1.0" signal-exit "^4.1.0"
rfdc@^1.4.1: ret@~0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/ret/-/ret-0.5.0.tgz#30a4d38a7e704bd96dc5ffcbe7ce2a9274c41c95"
integrity sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==
reusify@^1.0.4:
version "1.1.0"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rfdc@^1.2.0, rfdc@^1.3.1, rfdc@^1.4.1:
version "1.4.1" version "1.4.1"
resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca"
integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==
rimraf@^3.0.2: rimraf@^3.0.2:
@@ -7013,7 +7288,7 @@ safe-array-concat@^1.1.3:
has-symbols "^1.1.0" has-symbols "^1.1.0"
isarray "^2.0.5" isarray "^2.0.5"
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: safe-buffer@^5.0.1, safe-buffer@~5.2.0:
version "5.2.1" version "5.2.1"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -7035,6 +7310,18 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.2.1" is-regex "^1.2.1"
safe-regex2@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-regex2/-/safe-regex2-5.1.1.tgz#a5f3a6e35b8d84d0f41fa22efd5b6d30b367bbc7"
integrity sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==
dependencies:
ret "~0.5.0"
safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3:
version "2.5.0"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
"safer-buffer@>= 2.1.2 < 3.0.0": "safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
@@ -7057,6 +7344,11 @@ scroll-into-view-if-needed@^2.2.24:
dependencies: dependencies:
compute-scroll-into-view "^1.0.20" compute-scroll-into-view "^1.0.20"
secure-json-parse@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c"
integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==
semver@^5.6.0: semver@^5.6.0:
version "5.7.2" version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
@@ -7072,7 +7364,7 @@ semver@^7.3.5, semver@^7.5.3:
resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
semver@^7.7.4: semver@^7.6.0, semver@^7.7.4:
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
@@ -7111,7 +7403,7 @@ send@^1.2.0:
range-parser "^1.2.1" range-parser "^1.2.1"
statuses "^2.0.1" statuses "^2.0.1"
serve-static@2.2.1, serve-static@^2.2.0: serve-static@^2.2.0:
version "2.2.1" version "2.2.1"
resolved "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz" resolved "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz"
integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==
@@ -7301,6 +7593,13 @@ socks@^2.8.3:
ip-address "^10.0.1" ip-address "^10.0.1"
smart-buffer "^4.2.0" smart-buffer "^4.2.0"
sonic-boom@^4.0.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.2.1.tgz#28598250df4899c0ac572d7e2f0460690ba6a030"
integrity sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==
dependencies:
atomic-sleep "^1.0.0"
source-map-js@^1.2.1: source-map-js@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
@@ -7326,6 +7625,11 @@ split-on-first@^3.0.0:
resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz" resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz"
integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==
split2@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
stackback@0.0.2: stackback@0.0.2:
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
@@ -7584,6 +7888,13 @@ text-decoder@^1.1.0:
dependencies: dependencies:
b4a "^1.6.4" b4a "^1.6.4"
thread-stream@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-4.0.0.tgz#732f007c24da7084f729d6e3a7e3f5934a7380b7"
integrity sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==
dependencies:
real-require "^0.2.0"
tiny-json-http@^7.0.2: tiny-json-http@^7.0.2:
version "7.5.1" version "7.5.1"
resolved "https://registry.npmjs.org/tiny-json-http/-/tiny-json-http-7.5.1.tgz" resolved "https://registry.npmjs.org/tiny-json-http/-/tiny-json-http-7.5.1.tgz"
@@ -7632,6 +7943,11 @@ to-regex-range@^5.0.1:
dependencies: dependencies:
is-number "^7.0.0" is-number "^7.0.0"
toad-cache@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.0.tgz#b9b63304ea7c45ec34d91f1d2fa513517025c441"
integrity sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==
toidentifier@1.0.1, toidentifier@~1.0.1: toidentifier@1.0.1, toidentifier@~1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
@@ -7652,23 +7968,11 @@ trough@^2.0.0:
resolved "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz" resolved "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz"
integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==
trouter@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/trouter/-/trouter-4.0.0.tgz"
integrity sha512-bwwr76BThfiVwAFZqks5cJ+VoKNM3/2Yg1ZwJslkdmAUQ6S0UNoCoGYFDxdw+u1skfexggdmD2p35kW5Td4Cug==
dependencies:
regexparam "^3.0.0"
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.4.0, tslib@^2.8.1: tslib@^2.0.0, tslib@^2.0.1, tslib@^2.4.0, tslib@^2.8.1:
version "2.8.1" version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tsscmp@1.0.6:
version "1.0.6"
resolved "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz"
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
tunnel-agent@^0.6.0: tunnel-agent@^0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz"