(
-
-
- {getInitials(value)}
-
-
{value}
- {record.isAdmin && (
-
- ADMIN
-
- )}
-
- ),
- },
- {
- title: 'Last login',
- dataIndex: 'lastLogin',
- render: (value) => format(value),
- },
- {
- title: 'Jobs',
- dataIndex: 'numberOfJobs',
- },
- {
- title: 'MCP Token',
- dataIndex: 'mcpToken',
- render: (value) => (
-
- {value || '---'}
-
- ),
- },
- {
- title: '',
- dataIndex: 'tools',
- render: (_, record) => (
-
- }
- onClick={() => onUserRemoval(record.id)}
- />
- } onClick={() => onUserEdit(record.id)} />
-
- ),
- },
- ]}
- dataSource={user}
- />
- );
-}
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add ui/src/views/user/Users.jsx ui/src/views/user/Users.less ui/src/components/table/UserTable.jsx
-git commit -m "feat: users view with page heading, avatar initials, admin badge"
-```
-
----
-
-## Task 13: User Mutation (Create/Edit User)
-
-**Files:**
-- Modify: `ui/src/views/user/mutation/UserMutator.jsx`
-- Modify: `ui/src/views/user/mutation/UserMutator.less`
-
-- [ ] **Step 1: Read full `UserMutator.jsx`**
-
-```bash
-cat ui/src/views/user/mutation/UserMutator.jsx
-```
-
-- [ ] **Step 2: Add PageHeading + back button at the top of UserMutator.jsx**
-
-Add these imports if not present:
-```jsx
-import Headline from '../../../components/headline/Headline.jsx';
-import { IconArrowLeft } from '@douyinfe/semi-icons';
-```
-
-Inside the return (after the outer `` or ``), add as the very first element:
-
-```jsx
-}
- onClick={() => navigate('/users')}
- theme="borderless"
- style={{ color: '#909090' }}
- >
- Back
-
- }
-/>
-```
-
-- [ ] **Step 3: Replace `UserMutator.less`**
-
-```less
-@import '../../../tokens.less';
-
-.userMutator {
- display: flex;
- flex-direction: column;
-
- &__actions {
- display: flex;
- gap: @space-3;
- margin-top: @space-2;
- justify-content: flex-end;
- }
-}
-```
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add ui/src/views/user/mutation/UserMutator.jsx ui/src/views/user/mutation/UserMutator.less
-git commit -m "feat: user mutation with page heading and back button"
-```
-
----
-
-## Task 14: Settings View (GeneralSettings)
-
-**Files:**
-- Modify: `ui/src/views/generalSettings/GeneralSettings.jsx`
-- Modify: `ui/src/views/generalSettings/GeneralSettings.less`
-
-Add PageHeading. Style the tabs with red active indicator. The SegmentPart cards are already styled via Task 8.
-
-- [ ] **Step 1: Add `Headline` import to `GeneralSettings.jsx`**
-
-```jsx
-import Headline from '../../components/headline/Headline.jsx';
-```
-
-At the start of the return (before the `{!loading && ...}` check), add:
-
-```jsx
-<>
-
- {!loading && (
- // ... existing content
- )}
->
-```
-
-Wrap the whole return in a Fragment if needed.
-
-- [ ] **Step 2: Replace `ui/src/views/generalSettings/GeneralSettings.less`**
-
-```less
-@import '../../tokens.less';
-
-.generalSettings {
- display: flex;
- flex-direction: column;
- flex: 1;
-
- &__tab-content {
- padding: @space-4 0;
- }
-
- &__timePickerContainer {
- display: flex;
- gap: @space-3;
- flex-wrap: wrap;
- align-items: center;
- }
-
- &__save-row {
- display: flex;
- justify-content: flex-end;
- margin-top: @space-2;
- }
-}
-
-// Tabs styling
-.semi-tabs-bar-line .semi-tabs-tab {
- color: @color-faint;
- font-size: @text-base;
- transition: color @transition-fast;
-
- &:hover {
- color: @color-muted;
- }
-
- &.semi-tabs-tab-active {
- color: @color-text !important;
- }
-}
-
-.semi-tabs-bar-line .semi-tabs-ink-bar {
- background-color: @color-accent !important;
- height: 2px;
-}
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add ui/src/views/generalSettings/GeneralSettings.jsx ui/src/views/generalSettings/GeneralSettings.less
-git commit -m "feat: settings view with page heading and accent tab indicator"
-```
-
----
-
-## Task 15: Listing Detail
-
-**Files:**
-- Modify: `ui/src/views/listings/ListingDetail.jsx`
-- Modify: `ui/src/views/listings/ListingDetail.less`
-
-`WatchlistManagement.jsx` already imports and uses `Headline` — no changes needed there. `ListingDetail.jsx` already has a back button (`IconArrowLeft` + navigate('/listings')). It needs a `Headline` component at the top and a `tokens.less` import in its LESS file.
-
-- [ ] **Step 1: Add Headline to ListingDetail.jsx**
-
-Add this import at the top:
-```jsx
-import Headline from '../../components/headline/Headline.jsx';
-```
-
-Inside the return, find the existing back button (Button with `IconArrowLeft`). Replace the back-button + title row with a `Headline` that has the back button in the `actions` slot:
-
-```jsx
-}
- onClick={() => navigate('/listings')}
- theme="borderless"
- style={{ color: '#909090' }}
- >
- Back
-
- }
-/>
-```
-
-Remove the old separate back-button and title elements from the JSX.
-
-- [ ] **Step 2: Add tokens import to `ListingDetail.less`**
-
-At the top of `ui/src/views/listings/ListingDetail.less`, add:
-```less
-@import '../../tokens.less';
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add ui/src/views/listings/ListingDetail.jsx ui/src/views/listings/ListingDetail.less
-git commit -m "feat: listing detail view with page heading"
-```
-
----
-
-## Task 16: Final Polish + Verification
-
-**Files:**
-- Review all views in browser
-
-- [ ] **Step 1: Start dev server**
-
-```bash
-yarn dev
-```
-
-- [ ] **Step 2: Visual checklist — verify each screen**
-
-Open each route and check:
-- [ ] `/login` — glass card, Outfit font, red login button, correct card dimensions
-- [ ] `/dashboard` — PageHeading with gradient line, 4+4 KPI cards at 112px, no hover lift on KPI cards
-- [ ] `/jobs` — PageHeading with "New Job" button, cards with dark bg
-- [ ] `/jobs/new` — PageHeading "New Job", back button, section cards with correct dark style
-- [ ] `/jobs/edit/:id` — PageHeading "Edit Job", back button, same layout
-- [ ] `/listings` — PageHeading, card grid with image, star, price, action bar (blue/green/red icons)
-- [ ] `/map` — PageHeading "Map View", filter panel overlay at top-right
-- [ ] `/users` — PageHeading "Users", "+ New User" button, avatar initials, admin badge
-- [ ] `/users/new` — PageHeading "New User", back button
-- [ ] `/generalSettings` — PageHeading "Settings", tabs with red underline on active
-- [ ] Sidebar collapses at width <= 850px
-- [ ] Footer shows "Fredy vX.X.X" left, "Made with ❤️ by Christian Kellner" link right
-- [ ] Scrollbar is thin (6px) and dark
-
-- [ ] **Step 3: Mobile check (resize to 375px)**
-
-- [ ] Sidebar is collapsed and shows heart icon
-- [ ] Content padding is 12px
-- [ ] Listing cards are single-column
-- [ ] Login card fits viewport with no overflow
-
-- [ ] **Step 4: Final commit**
-
-```bash
-git add -A
-git commit -m "feat: Fredy UI redesign — complete cronpilot CI implementation"
-```
-
----
-
-## Appendix: Design Token Quick Reference
-
-| Token | Value | Usage |
-|---|---|---|
-| `@color-base` | `#0d0d0d` | Body background |
-| `@color-surface` | `#161616` | Cards, sidebar |
-| `@color-elevated` | `#1e1e1e` | Listing cards, modals |
-| `@color-border` | `#2a2a2a` | All borders |
-| `@color-accent` | `#e04a38` | Buttons, active states |
-| `@color-text` | `#efefef` | Primary text |
-| `@color-muted` | `#909090` | Secondary text |
-| `@color-faint` | `#505050` | Disabled, labels |
-| `@font-ui` | Outfit | All UI text |
-| `@font-mono` | JetBrains Mono | Code, tokens |
diff --git a/lib/api/api.js b/lib/api/api.js
index 2ba75c5..be7ccc5 100644
--- a/lib/api/api.js
+++ b/lib/api/api.js
@@ -3,64 +3,100 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
-import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
-import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
-import { providerRouter } from './routes/providerRouter.js';
-import { versionRouter } from './routes/versionRouter.js';
-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 Fastify from 'fastify';
+import fastifyHelmet from '@fastify/helmet';
+import fastifyCookie from '@fastify/cookie';
+import fastifySession from '@fastify/session';
+import fastifyStatic from '@fastify/static';
import path from 'path';
import { getDirName } from '../utils.js';
-import { demoRouter } from './routes/demoRouter.js';
-import logger from '../services/logger.js';
-import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
-import { dashboardRouter } from './routes/dashboardRouter.js';
-import { backupRouter } from './routes/backupRouter.js';
-import { trackingRouter } from './routes/trackingRoute.js';
+import logger from '../services/logger.js';
+import { authHook, adminHook } from './security.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';
-const service = restana();
-const staticService = files(path.join(getDirName(), '../ui/public'));
+
const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret();
+const SESSION_MAX_AGE = 2 * 60 * 60 * 1000;
-service.use(bodyParser.json());
-service.use(cookieSession(sessionSecret));
-service.use(staticService);
-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}`);
+const fastify = Fastify({
+ logger: false,
+ bodyLimit: 50 * 1024 * 1024, // 50 MB for backup uploads
});
+
+// 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}`);
diff --git a/lib/api/routes/backupRouter.js b/lib/api/routes/backupRouter.js
index 19eca56..a4e5d60 100644
--- a/lib/api/routes/backupRouter.js
+++ b/lib/api/routes/backupRouter.js
@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import restana from 'restana';
import {
buildBackupFileName,
createBackupZip,
@@ -12,64 +11,41 @@ import {
} from '../../services/storage/backupRestoreService.js';
/**
- * Backup & Restore Admin Router
- *
- * 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.
+ * @param {import('fastify').FastifyInstance} fastify
*/
-const service = restana();
-const backupRouter = service.newRouter();
+export default async function backupPlugin(fastify) {
+ // 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) => {
- const zipBuffer = await createBackupZip();
- const fileName = await buildBackupFileName();
- res.setHeader('Content-Type', 'application/zip');
- res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
- res.send(zipBuffer);
-});
+ fastify.get('/', async (_request, reply) => {
+ const zipBuffer = await createBackupZip();
+ const fileName = await buildBackupFileName();
+ reply.header('Content-Type', 'application/zip');
+ reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
+ return reply.send(zipBuffer);
+ });
-/**
- * Read the full request body as a Buffer. Used for raw zip uploads.
- * @param {import('http').IncomingMessage} req
- * @returns {Promise}
- */
-function readBody(req) {
- return new Promise((resolve, reject) => {
- const chunks = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => resolve(Buffer.concat(chunks)));
- req.on('error', (e) => reject(e));
+ fastify.post('/restore', async (request, reply) => {
+ const { dryRun = 'false', force = 'false' } = request.query || {};
+ const doDryRun = String(dryRun) === 'true';
+ const doForce = String(force) === 'true';
+ const body = request.body; // Buffer from addContentTypeParser
+
+ if (doDryRun) {
+ return precheckRestore(body);
+ }
+
+ 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 };
diff --git a/lib/api/routes/dashboardRouter.js b/lib/api/routes/dashboardRouter.js
index b72d804..a2382ed 100644
--- a/lib/api/routes/dashboardRouter.js
+++ b/lib/api/routes/dashboardRouter.js
@@ -3,23 +3,14 @@
* 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 userStorage from '../../services/storage/userStorage.js';
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
+import { isAdmin } from '../security.js';
-const service = restana();
-export const dashboardRouter = service.newRouter();
-
-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);
+function getAccessibleJobs(request) {
+ const currentUser = request.session.currentUser;
+ const admin = isAdmin(request);
return jobStorage
.getJobs()
.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);
}
-dashboardRouter.get('/', async (req, res) => {
- const jobs = getAccessibleJobs(req);
- const settings = await getSettings();
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function dashboardPlugin(fastify) {
+ fastify.get('/', async (request) => {
+ const jobs = getAccessibleJobs(request);
+ const settings = await getSettings();
- // KPIs
- const totalJobs = jobs.length;
- const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
- const jobIds = jobs.map((j) => j.id);
- const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
- // Build Pie data in a simple shape the frontend can consume directly
- // Shape: { labels: string[], values: number[] } with values as percentages
- 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'
+ const totalJobs = jobs.length;
+ const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
+ const jobIds = jobs.map((j) => j.id);
+ const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
+
+ const providerPieRaw = getProviderDistributionForJobIds(jobIds);
+ const providerPie = Array.isArray(providerPieRaw)
? {
- labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
- values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
+ labels: providerPieRaw.map((p) => cap(p.type)),
+ 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 = {
- general: {
- interval: settings.interval,
- lastRun: settings.lastRun || null,
- nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
- },
- kpis: {
- totalJobs,
- totalListings,
- numberOfActiveListings,
- medianPriceOfListings,
- },
- pie: providerPie,
- };
- res.send();
-});
+ return {
+ general: {
+ interval: settings.interval,
+ lastRun: settings.lastRun || null,
+ nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
+ },
+ kpis: {
+ totalJobs,
+ totalListings,
+ numberOfActiveListings,
+ medianPriceOfListings,
+ },
+ pie: providerPie,
+ };
+ });
+}
diff --git a/lib/api/routes/demoRouter.js b/lib/api/routes/demoRouter.js
index 4e3be22..923d700 100644
--- a/lib/api/routes/demoRouter.js
+++ b/lib/api/routes/demoRouter.js
@@ -3,15 +3,14 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import restana from 'restana';
import { getSettings } from '../../services/storage/settingsStorage.js';
-const service = restana();
-const demoRouter = service.newRouter();
-demoRouter.get('/', async (req, res) => {
- const settings = await getSettings();
- res.body = Object.assign({}, { demoMode: settings.demoMode });
- res.send();
-});
-
-export { demoRouter };
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function demoPlugin(fastify) {
+ fastify.get('/', async () => {
+ const settings = await getSettings();
+ return { demoMode: settings.demoMode };
+ });
+}
diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js
index fa63fda..2bbddce 100644
--- a/lib/api/routes/generalSettingsRoute.js
+++ b/lib/api/routes/generalSettingsRoute.js
@@ -3,43 +3,42 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import restana from 'restana';
import { getDirName } from '../../utils.js';
import fs from 'fs';
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
import logger from '../../services/logger.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
-const service = restana();
-const generalSettingsRouter = service.newRouter();
-generalSettingsRouter.get('/', async (req, res) => {
- res.body = Object.assign({}, await getSettings());
- res.send();
-});
-generalSettingsRouter.post('/', async (req, res) => {
- const { sqlitepath, ...appSettings } = req.body || {};
- if (typeof appSettings.baseUrl === 'string') {
- appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
- }
- const localSettings = await getSettings();
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function generalSettingsPlugin(fastify) {
+ fastify.get('/', async () => {
+ return Object.assign({}, await getSettings());
+ });
- if (localSettings.demoMode && !isAdmin(req)) {
- res.send(new Error('In demo mode, it is not allowed to change these settings.'));
- return;
- }
-
- try {
- if (typeof sqlitepath !== 'undefined') {
- fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
+ fastify.post('/', async (request, reply) => {
+ const { sqlitepath, ...appSettings } = request.body || {};
+ if (typeof appSettings.baseUrl === 'string') {
+ appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
}
- upsertSettings(appSettings);
- ensureDemoUserExists();
- } catch (err) {
- logger.error(err);
- res.send(new Error('Error while trying to write settings.'));
- return;
- }
- res.send();
-});
-export { generalSettingsRouter };
+ const localSettings = await getSettings();
+
+ if (localSettings.demoMode && !isAdmin(request)) {
+ return reply.code(403).send({ error: 'In demo mode, it is not allowed to change these settings.' });
+ }
+
+ try {
+ if (typeof sqlitepath !== 'undefined') {
+ fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
+ }
+ upsertSettings(appSettings);
+ ensureDemoUserExists();
+ } catch (err) {
+ logger.error(err);
+ return reply.code(500).send({ error: 'Error while trying to write settings.' });
+ }
+ return reply.send();
+ });
+}
diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js
index e0e53a8..8ea68f8 100644
--- a/lib/api/routes/jobRouter.js
+++ b/lib/api/routes/jobRouter.js
@@ -3,7 +3,6 @@
* 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 userStorage from '../../services/storage/userStorage.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 { getSettings } from '../../services/storage/settingsStorage.js';
-const service = restana();
-const jobRouter = service.newRouter();
-
const DEMO_JOB_NAME = 'Demo-Job';
-function doesJobBelongsToUser(job, req) {
- const userId = req.session.currentUser;
- if (userId == null) {
- return false;
- }
+function doesJobBelongsToUser(job, request) {
+ const userId = request.session.currentUser;
+ if (userId == null) return false;
const user = userStorage.getUser(userId);
- if (user == null) {
- return false;
- }
+ if (user == null) return false;
return user.isAdmin || job.userId === user.id;
}
-jobRouter.get('/', async (req, res) => {
- const isUserAdmin = isAdmin(req);
- //show only the jobs which belongs to the user (or all of the user is an admin)
- res.body = jobStorage
- .getJobs()
- .filter(
- (job) =>
- isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
- )
- .map((job) => {
- return {
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function jobPlugin(fastify) {
+ fastify.get('/', async (request) => {
+ const isUserAdmin = isAdmin(request);
+ return jobStorage
+ .getJobs()
+ .filter(
+ (job) =>
+ isUserAdmin ||
+ job.userId === request.session.currentUser ||
+ job.shared_with_user.includes(request.session.currentUser),
+ )
+ .map((job) => ({
...job,
running: isJobRunning(job.id),
isOnlyShared:
!isUserAdmin &&
- job.userId !== req.session.currentUser &&
- job.shared_with_user.includes(req.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),
+ job.userId !== request.session.currentUser &&
+ job.shared_with_user.includes(request.session.currentUser),
+ }));
});
- 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
- queryResult.result = queryResult.result.map((job) => {
- return {
+ 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: request.session.currentUser,
+ isAdmin: isAdmin(request),
+ });
+
+ const isUserAdmin = isAdmin(request);
+ queryResult.result = queryResult.result.map((job) => ({
...job,
running: isJobRunning(job.id),
isOnlyShared:
!isUserAdmin &&
- job.userId !== req.session.currentUser &&
- job.shared_with_user.includes(req.session.currentUser),
- };
+ job.userId !== request.session.currentUser &&
+ job.shared_with_user.includes(request.session.currentUser),
+ }));
+
+ return queryResult;
});
- res.body = queryResult;
- res.send();
-});
+ // Server-Sent Events for real-time job status updates
+ 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 {
- res.end();
- } catch {
- //noop
+ raw.write(': connected\n\n');
+ addSseClient(userId, raw);
+ 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) => {
- try {
- const userId = req.session.currentUser;
- // Emit only the userId; handler will decide based on admin/ownership
- bus.emit('jobs:runAll', { userId });
- res.send({ message: 'Run all accepted' }, 202);
- } catch (err) {
- logger.error('Failed to trigger startAll', err);
- 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;
+ fastify.post('/startAll', async (request, reply) => {
+ try {
+ const userId = request.session.currentUser;
+ bus.emit('jobs:runAll', { userId });
+ return reply.code(202).send({ message: 'Run all accepted' });
+ } catch (err) {
+ logger.error('Failed to trigger startAll', err);
+ return reply.code(500).send({ message: 'Unexpected error' });
}
- 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) => {
- const {
- provider,
- notificationAdapter,
- name,
- blacklist = [],
- jobId,
- enabled,
- shareWithUsers = [],
- spatialFilter = null,
- specFilter = null,
- } = req.body;
- const settings = await getSettings();
- try {
- let jobFromDb = jobStorage.getJob(jobId);
-
- if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
- res.send(new Error('You are trying to change a job that is not associated to your user.'));
- return;
+ fastify.post('/:jobId/run', async (request, reply) => {
+ const { jobId } = request.params;
+ try {
+ const job = jobStorage.getJob(jobId);
+ if (!job) {
+ return reply.code(404).send({ message: 'Job not found' });
+ }
+ if (!doesJobBelongsToUser(job, request)) {
+ return reply.code(403).send({ message: 'You are trying to run a job that is not associated to your user' });
+ }
+ if (isJobRunning(jobId)) {
+ return reply.code(409).send({ message: 'Job is already running' });
+ }
+ bus.emit('jobs:runOne', { jobId });
+ return reply.code(202).send({ message: 'Job run accepted' });
+ } catch (error) {
+ logger.error(error);
+ return reply.code(500).send({ message: 'Unexpected error triggering job' });
}
+ });
- if (settings.demoMode && !isAdmin(req) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
- res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
- return;
- }
-
- jobStorage.upsertJob({
- userId: req.session.currentUser,
- jobId,
- enabled,
- name,
- blacklist,
+ fastify.post('/', async (request, reply) => {
+ const {
provider,
notificationAdapter,
- shareWithUsers,
- spatialFilter,
- specFilter,
- });
- } catch (error) {
- res.send(new Error(error));
- logger.error(error);
- }
- res.send();
-});
+ name,
+ blacklist = [],
+ jobId,
+ enabled,
+ shareWithUsers = [],
+ spatialFilter = null,
+ specFilter = null,
+ } = request.body;
+ const settings = await getSettings();
+ try {
+ const jobFromDb = jobStorage.getJob(jobId);
-jobRouter.delete('', async (req, res) => {
- const { jobId } = req.body;
- 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 (jobFromDb && !doesJobBelongsToUser(jobFromDb, request)) {
+ return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user.' });
+ }
- if (!doesJobBelongsToUser(job, req)) {
- res.send(new Error('You are trying to remove a job that is not associated to your user'));
- } 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(request) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
+ return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
+ }
- if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) {
- res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
- 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({
+ jobStorage.upsertJob({
+ userId: request.session.currentUser,
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) {
- res.send(new Error(error));
- logger.error(error);
- }
- res.send();
-});
+ return reply.send();
+ });
-jobRouter.get('/shareableUserList', async (req, res) => {
- const currentUser = req.session.currentUser;
- const users = userStorage.getUsers(false);
- res.body = users
- .filter((user) => !user.isAdmin && user.id !== currentUser)
- .map((user) => ({
- id: user.id,
- name: user.username,
- }));
- res.send();
-});
-export { jobRouter };
+ fastify.delete('/', async (request, reply) => {
+ const { jobId } = request.body;
+ 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 remove the Demo Job ;)' });
+ }
+
+ if (!doesJobBelongsToUser(job, request)) {
+ return reply.code(403).send({ error: 'You are trying to remove a job that is not associated to your user' });
+ }
+ 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,
+ }));
+ });
+}
diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js
index c0b1386..7ab3c58 100644
--- a/lib/api/routes/listingsRouter.js
+++ b/lib/api/routes/listingsRouter.js
@@ -3,7 +3,6 @@
* 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 watchListStorage from '../../services/storage/watchListStorage.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 { 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) => {
- const {
- page,
- pageSize = 50,
- activityFilter,
- jobNameFilter,
- providerFilter,
- watchListFilter,
- sortfield = null,
- sortdir = 'asc',
- freeTextFilter,
- } = req.query || {};
+ let jobFilter = null;
+ let jobIdFilter = null;
+ const jobs = getJobs();
+ if (!nullOrEmpty(jobNameFilter)) {
+ const job = jobs.find((j) => j.id === jobNameFilter);
+ jobFilter = job != null ? job.name : null;
+ jobIdFilter = job != null ? job.id : null;
+ }
- // normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
- 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);
-
- let jobFilter = null;
- let jobIdFilter = null;
- const jobs = getJobs();
- if (!nullOrEmpty(jobNameFilter)) {
- 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),
+ return 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: request.session.currentUser,
+ isAdmin: isAdminFn(request),
+ });
});
- res.send();
-});
-listingsRouter.get('/map', async (req, res) => {
- const { jobId } = req.query || {};
-
- res.body = listingStorage.getListingsForMap({
- jobId: nullOrEmpty(jobId) ? null : jobId,
- userId: req.session.currentUser,
- isAdmin: isAdminFn(req),
+ fastify.get('/map', async (request) => {
+ const { jobId } = request.query || {};
+ return listingStorage.getListingsForMap({
+ jobId: nullOrEmpty(jobId) ? null : jobId,
+ userId: request.session.currentUser,
+ isAdmin: isAdminFn(request),
+ });
});
- res.send();
-});
-listingsRouter.get('/:listingId', async (req, res) => {
- const { listingId } = req.params;
- const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
- if (!listing) {
- res.statusCode = 404;
- 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();
+ fastify.get('/:listingId', async (request, reply) => {
+ const { listingId } = request.params;
+ const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
+ if (!listing) {
+ return reply.code(404).send({ message: 'Listing not found' });
}
- watchListStorage.toggleWatch(listingId, userId);
- } catch (error) {
- logger.error(error);
- res.statusCode = 500;
- res.body = { message: 'Failed to toggle watch' };
- }
- res.send();
-});
+ return listing;
+ });
-listingsRouter.delete('/job', async (req, res) => {
- const { jobId, hardDelete = false } = req.body;
- const settings = await getSettings();
- try {
- if (settings.demoMode && !isAdminFn(req)) {
- res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
- return;
+ fastify.post('/watch', async (request, reply) => {
+ try {
+ const { listingId } = request.body || {};
+ const userId = request.session?.currentUser;
+ if (!listingId || !userId) {
+ return reply.code(400).send({ message: 'listingId or user not provided' });
+ }
+ 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);
- } catch (error) {
- res.send(new Error(error));
- logger.error(error);
- }
- res.send();
-});
-
-listingsRouter.delete('/', async (req, res) => {
- const { ids, hardDelete = false } = req.body;
- try {
- if (Array.isArray(ids) && ids.length > 0) {
- listingStorage.deleteListingsById(ids, hardDelete);
+ fastify.delete('/job', async (request, reply) => {
+ const { jobId, hardDelete = false } = request.body;
+ const settings = await getSettings();
+ try {
+ if (settings.demoMode && !isAdminFn(request)) {
+ return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
+ }
+ listingStorage.deleteListingsByJobId(jobId, hardDelete);
+ } catch (error) {
+ logger.error(error);
+ return reply.code(500).send({ error: error.message });
}
- } catch (error) {
- res.send(new Error(error));
- logger.error(error);
- }
- res.send();
-});
+ return reply.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();
+ });
+}
diff --git a/lib/api/routes/loginRoute.js b/lib/api/routes/loginRoute.js
index 15f4aac..19809e4 100644
--- a/lib/api/routes/loginRoute.js
+++ b/lib/api/routes/loginRoute.js
@@ -3,7 +3,6 @@
* 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 hasher from '../../services/security/hash.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';
const MAX_LOGIN_ATTEMPTS = 10;
-const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
-const loginAttempts = new Map(); // ip -> { count, firstAttempt }
+const LOGIN_WINDOW_MS = 15 * 60 * 1000;
+const loginAttempts = new Map();
-function getClientIp(req) {
- const forwarded = req.headers['x-forwarded-for'];
- return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown';
+function getClientIp(request) {
+ const forwarded = request.headers['x-forwarded-for'];
+ return (forwarded ? forwarded.split(',')[0] : request.socket?.remoteAddress) || 'unknown';
}
function isRateLimited(ip) {
@@ -30,53 +29,51 @@ function isRateLimited(ip) {
return record.count > MAX_LOGIN_ATTEMPTS;
}
-const service = restana();
-const loginRouter = service.newRouter();
-loginRouter.get('/user', async (req, res) => {
- const currentUserId = req.session.currentUser;
- const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
- if (currentUser == null) {
- res.body = {};
- } else {
- res.body = {
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function loginPlugin(fastify) {
+ fastify.get('/user', async (request) => {
+ const currentUserId = request.session?.currentUser;
+ const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
+ if (currentUser == null) {
+ return {};
+ }
+ return {
userId: currentUser.id,
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;
- req.session.createdAt = Date.now();
- loginAttempts.delete(ip);
- userStorage.setLastLoginToNow({ userId: user.id });
- res.send(200);
- return;
- } else {
- logger.error(`User ${username} tried to login, but password was wrong.`);
- }
- res.send(401);
-});
-loginRouter.post('/logout', async (req, res) => {
- req.session = null;
- res.send(200);
-});
-export { loginRouter };
+ fastify.post('/', async (request, reply) => {
+ const ip = getClientIp(request);
+ if (isRateLimited(ip)) {
+ logger.error(`Login rate limit exceeded for IP ${ip}`);
+ return reply.code(429).send();
+ }
+ const settings = await getSettings();
+ const { username, password } = request.body;
+ const user = userStorage.getUsers(true).find((u) => u.username === username);
+ if (user == null) {
+ return reply.code(401).send();
+ }
+ if (user.password === hasher.hash(password)) {
+ if (settings.demoMode) {
+ await trackDemoAccessed();
+ }
+ 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();
+ });
+}
diff --git a/lib/api/routes/notificationAdapterRouter.js b/lib/api/routes/notificationAdapterRouter.js
index eeb5e68..5746c4e 100644
--- a/lib/api/routes/notificationAdapterRouter.js
+++ b/lib/api/routes/notificationAdapterRouter.js
@@ -4,62 +4,64 @@
*/
import fs from 'fs';
-import restana from 'restana';
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 notificationAdapter = await Promise.all(
notificationAdapterList.map(async (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);
- if (adapter == null) {
- res.send(404);
- }
- const notificationConfig = [];
- const notificationObject = {};
- Object.keys(fields).forEach((key) => {
- notificationObject[key] = fields[key].value;
+
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function notificationAdapterPlugin(fastify) {
+ fastify.get('/', async () => {
+ return notificationAdapter.map((adapter) => adapter.config);
});
- notificationConfig.push({
- fields: { ...notificationObject },
- enabled: true,
- id,
- });
- try {
- await adapter.send({
- serviceName: 'TestCall',
- newListings: [
- {
- 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',
+
+ fastify.post('/try', async (request, reply) => {
+ const { id, fields } = request.body;
+ const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
+ if (adapter == null) {
+ return reply.code(404).send();
+ }
+ const notificationConfig = [];
+ const notificationObject = {};
+ Object.keys(fields).forEach((key) => {
+ notificationObject[key] = fields[key].value;
});
- res.send();
- } catch (Exception) {
- logger.error('Error during notification adapter test:', Exception);
- res.send(new Error(Exception));
- }
-});
-notificationAdapterRouter.get('/', async (req, res) => {
- res.body = notificationAdapter.map((adapter) => adapter.config);
- res.send();
-});
-export { notificationAdapterRouter };
+ notificationConfig.push({
+ fields: { ...notificationObject },
+ enabled: true,
+ id,
+ });
+ try {
+ await adapter.send({
+ serviceName: 'TestCall',
+ newListings: [
+ {
+ 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 = `
Wohnungstyp: Etagenwohnung
@@ -94,7 +96,7 @@ Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
Ausstattung:
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
- sonniger Balkon (Süd)
-- Tiefgaragenstellplatz
+- Tiefgaragenstellplatz
- Kellerabteil
- gepflegtes Mehrfamilienhaus
@@ -104,7 +106,7 @@ Vermietung direkt vom Eigentümer - provisionsfrei!
Lage:
• 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
• Gute Anbindung Richtung Großstadt und Flughafen
`;
diff --git a/lib/api/routes/providerRouter.js b/lib/api/routes/providerRouter.js
index 5134f7d..bb31e0c 100644
--- a/lib/api/routes/providerRouter.js
+++ b/lib/api/routes/providerRouter.js
@@ -4,17 +4,15 @@
*/
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 provider = await Promise.all(
- providerList.map(async (pro) => {
- return await import(`../../provider/${pro}`);
- }),
-);
-providerRouter.get('/', async (req, res) => {
- res.body = provider.map((p) => p.metaInformation);
- res.send();
-});
-export { providerRouter };
+const providers = await Promise.all(providerList.map(async (pro) => import(`../../provider/${pro}`)));
+
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function providerPlugin(fastify) {
+ fastify.get('/', async () => {
+ return providers.map((p) => p.metaInformation);
+ });
+}
diff --git a/lib/api/routes/trackingRoute.js b/lib/api/routes/trackingRoute.js
index 8231e34..c1d49ad 100644
--- a/lib/api/routes/trackingRoute.js
+++ b/lib/api/routes/trackingRoute.js
@@ -3,35 +3,29 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import restana from 'restana';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.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) => {
- res.body = TRACKING_POIS;
- res.send();
-});
-
-trackingRouter.post('/poi', async (req, res) => {
- const { poi } = req.body;
- if (!poi) {
- res.statusCode = 400;
- res.send({ error: 'Feature name is required' });
- return;
- }
-
- 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 };
+ fastify.post('/poi', async (request, reply) => {
+ const { poi } = request.body;
+ if (!poi) {
+ return reply.code(400).send({ error: 'Feature name is required' });
+ }
+ try {
+ await trackPoi(poi);
+ return { success: true };
+ } catch (error) {
+ logger.error('Error tracking feature', error);
+ return reply.code(500).send({ error: error.message });
+ }
+ });
+}
diff --git a/lib/api/routes/userRoute.js b/lib/api/routes/userRoute.js
index a62b0be..1f60c88 100644
--- a/lib/api/routes/userRoute.js
+++ b/lib/api/routes/userRoute.js
@@ -3,82 +3,73 @@
* 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 jobStorage from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin as isAdminUser } from '../security.js';
-const service = restana();
-const userRouter = service.newRouter();
+
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
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;
-userRouter.get('/', async (req, res) => {
- res.body = userStorage.getUsers(false);
- res.send();
-});
-
-userRouter.get('/:userId', async (req, res) => {
- 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,
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function userPlugin(fastify) {
+ fastify.get('/', async () => {
+ return userStorage.getUsers(false);
});
- res.send();
-});
-export { userRouter };
+
+ fastify.get('/:userId', async (request) => {
+ 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();
+ });
+}
diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js
index 3090cac..294dadd 100644
--- a/lib/api/routes/userSettingsRoute.js
+++ b/lib/api/routes/userSettingsRoute.js
@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import restana from 'restana';
import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
@@ -16,113 +15,98 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
-const service = restana();
-const userSettingsRouter = service.newRouter();
-
-userSettingsRouter.get('/', async (req, res) => {
- const userId = req.session.currentUser;
- const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
- const settings = {};
- 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 });
+/**
+ * @param {import('fastify').FastifyInstance} fastify
+ */
+export default async function userSettingsPlugin(fastify) {
+ fastify.get('/', async (request) => {
+ const userId = request.session.currentUser;
+ const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
+ const settings = {};
+ for (const r of rows) {
+ settings[r.name] = fromJson(r.value, null);
}
- } catch (error) {
- logger.error('Error updating home address settings', error);
- res.statusCode = 500;
- res.send({ error: error.message });
- }
-});
+ return settings;
+ });
-userSettingsRouter.post('/news-hash', async (req, res) => {
- const userId = req.session.currentUser;
- const { news_hash } = req.body;
+ fastify.get('/autocomplete', async (request, reply) => {
+ const { q } = request.query;
+ try {
+ const results = await autocompleteAddress(q);
+ return results;
+ } catch (error) {
+ return reply.code(500).send({ error: error.message });
+ }
+ });
- const globalSettings = await getSettings();
- if (globalSettings.demoMode && !isAdmin(req)) {
- res.statusCode = 403;
- res.send({ error: 'In demo mode, it is not allowed to change settings.' });
- return;
- }
+ fastify.post('/home-address', async (request, reply) => {
+ const userId = request.session.currentUser;
+ const { home_address } = request.body;
+ const settings = await getSettings();
- try {
- upsertSettings({ news_hash }, userId);
- res.send({ success: true });
- } catch (error) {
- logger.error('Error updating news hash', error);
- res.statusCode = 500;
- res.send({ error: error.message });
- }
-});
+ if (settings.demoMode && !isAdmin(request)) {
+ return reply.code(403).send({ error: 'In demo mode, it is not allowed to change the home address.' });
+ }
-userSettingsRouter.post('/provider-details', async (req, res) => {
- const userId = req.session.currentUser;
- const { provider_details } = req.body;
+ 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);
+ 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();
- if (globalSettings.demoMode && !isAdmin(req)) {
- res.statusCode = 403;
- res.send({ error: 'In demo mode, it is not allowed to change settings.' });
- return;
- }
+ fastify.post('/news-hash', async (request, reply) => {
+ const userId = request.session.currentUser;
+ const { news_hash } = request.body;
- if (!Array.isArray(provider_details)) {
- res.statusCode = 400;
- res.send({ error: 'provider_details must be an array of provider ids.' });
- return;
- }
+ 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.' });
+ }
- try {
- upsertSettings({ provider_details }, userId);
- res.send({ success: true });
- } catch (error) {
- logger.error('Error updating provider details setting', error);
- res.statusCode = 500;
- res.send({ error: error.message });
- }
-});
+ try {
+ upsertSettings({ news_hash }, userId);
+ return { success: true };
+ } catch (error) {
+ logger.error('Error updating news hash', error);
+ return reply.code(500).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 });
+ }
+ });
+}
diff --git a/lib/api/routes/versionRouter.js b/lib/api/routes/versionRouter.js
index 22f01fd..f14309f 100644
--- a/lib/api/routes/versionRouter.js
+++ b/lib/api/routes/versionRouter.js
@@ -3,27 +3,10 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import restana from 'restana';
import fetch from 'node-fetch';
import { getPackageVersion } from '../../utils.js';
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() {
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
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 };
+ });
+}
diff --git a/lib/api/security.js b/lib/api/security.js
index 6edf986..acaba00 100644
--- a/lib/api/security.js
+++ b/lib/api/security.js
@@ -4,53 +4,50 @@
*/
import * as userStorage from '../services/storage/userStorage.js';
-import cookieSession from 'cookie-session';
+
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
-const unauthorized = (res) => {
- return res.send(401);
-};
-const isUnauthorized = (req) => {
- if (req.session.currentUser == null) return true;
- if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
- req.session = null;
- return true;
- }
+
+/**
+ * Returns true when the request has no valid, non-expired session.
+ * @param {import('fastify').FastifyRequest} request
+ * @returns {boolean}
+ */
+export function isUnauthorized(request) {
+ if (!request.session?.currentUser) return true;
+ if (Date.now() - (request.session.createdAt || 0) > SESSION_MAX_AGE) return true;
return false;
-};
-const isAdmin = (req) => {
- if (!isUnauthorized(req)) {
- const user = userStorage.getUser(req.session.currentUser);
- return user != null && user.isAdmin;
+}
+
+/**
+ * Returns true when the session belongs to an admin user.
+ * @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) => {
- if (isUnauthorized(req)) {
- return unauthorized(res);
- } else {
- next();
- }
- };
-};
-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 };
+}
+
+/**
+ * Fastify preHandler hook - rejects non-admin requests with 401.
+ * Apply after authHook.
+ * @param {import('fastify').FastifyRequest} request
+ * @param {import('fastify').FastifyReply} reply
+ */
+export async function adminHook(request, reply) {
+ if (!isAdmin(request)) {
+ reply.code(401).send();
+ }
+}
diff --git a/lib/mcp/README.md b/lib/mcp/README.md
index bba9c35..192b35d 100644
--- a/lib/mcp/README.md
+++ b/lib/mcp/README.md
@@ -133,7 +133,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
#### Setup
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:
```json
@@ -158,7 +158,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
> - nvm: `/Users//.nvm/versions/node//bin/node`
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
@@ -170,7 +170,7 @@ Once connected, simply ask Claude about your real estate data:
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
-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 |
|----|-------|---------|-------|------|----------|--------|---------|-----|
diff --git a/lib/mcp/mcpAdapter.js b/lib/mcp/mcpAdapter.js
index 7ef6531..52f72b1 100644
--- a/lib/mcp/mcpAdapter.js
+++ b/lib/mcp/mcpAdapter.js
@@ -49,7 +49,7 @@ export function createMcpServer() {
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
'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. ' +
- '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.',
},
);
diff --git a/lib/mcp/mcpHttpRoute.js b/lib/mcp/mcpHttpRoute.js
index 3115f8a..452493e 100644
--- a/lib/mcp/mcpHttpRoute.js
+++ b/lib/mcp/mcpHttpRoute.js
@@ -3,10 +3,6 @@
* 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 { createMcpServer } from './mcpAdapter.js';
import { authenticateRequest } from './mcpAuthentication.js';
@@ -15,16 +11,13 @@ import crypto from 'crypto';
/**
* Active transports keyed by session id.
- * Each session gets its own McpServer + StreamableHTTPServerTransport pair.
* @type {Map}
*/
const sessions = new Map();
/**
- * Get or create a session for the given session id with authentication.
* @param {string|undefined} sessionId
* @param {{ userId: string }} auth
- * @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
*/
function getOrCreateSession(sessionId, auth) {
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 (initialize, tool calls, etc.)
- * - GET /api/mcp – SSE stream for server-initiated notifications
- * - DELETE /api/mcp – session termination
+ * POST /api/mcp – JSON-RPC messages
+ * GET /api/mcp – SSE stream for server-initiated notifications
+ * DELETE /api/mcp – session termination
*
* 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) {
- // POST – main JSON-RPC endpoint
- service.post('/api/mcp', async (req, res) => {
- const auth = authenticateRequest(req);
+export function registerMcpRoutes(fastify) {
+ fastify.post('/api/mcp', async (request, reply) => {
+ const auth = authenticateRequest(request.raw);
if (!auth) {
- res.statusCode = 401;
- return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
+ return reply.code(401).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);
- // Connect server to transport if not already connected
if (!transport.onmessage) {
await server.connect(transport);
}
- // Inject authInfo so tools can access the authenticated user
- req.auth = { userId: auth.userId };
+ request.raw.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
- service.get('/api/mcp', async (req, res) => {
- const auth = authenticateRequest(req);
+ fastify.get('/api/mcp', async (request, reply) => {
+ const auth = authenticateRequest(request.raw);
if (!auth) {
- res.statusCode = 401;
- return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
+ return reply.code(401).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)) {
- res.statusCode = 400;
- return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
+ return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
}
const { transport } = sessions.get(sessionId);
- await transport.handleRequest(req, res);
+ reply.hijack();
+ await transport.handleRequest(request.raw, reply.raw);
});
- // DELETE – terminate session
- service.delete('/api/mcp', async (req, res) => {
- const auth = authenticateRequest(req);
+ fastify.delete('/api/mcp', async (request, reply) => {
+ const auth = authenticateRequest(request.raw);
if (!auth) {
- res.statusCode = 401;
- return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
+ return reply.code(401).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)) {
- res.statusCode = 404;
- return res.send({ error: 'Session not found.' });
+ return reply.code(404).send({ error: 'Session not found.' });
}
const { transport } = sessions.get(sessionId);
await transport.close();
sessions.delete(sessionId);
- res.statusCode = 200;
- res.send({ ok: true });
+ return { ok: true };
});
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
diff --git a/lib/mcp/mcpNormalizer.js b/lib/mcp/mcpNormalizer.js
index 6d89874..12cf8a5 100644
--- a/lib/mcp/mcpNormalizer.js
+++ b/lib/mcp/mcpNormalizer.js
@@ -70,7 +70,7 @@ export function normalizeListJobs(queryResult, { page, pageSize }) {
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).`;
- 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';
if (jobs.length > 0) {
@@ -120,7 +120,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
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).`;
- 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';
if (listings.length > 0) {
diff --git a/lib/notification/adapter/smtp.md b/lib/notification/adapter/smtp.md
index 28f7887..b1be0e0 100644
--- a/lib/notification/adapter/smtp.md
+++ b/lib/notification/adapter/smtp.md
@@ -17,6 +17,6 @@ Multiple recipients:
Common SMTP settings:
-- **Gmail** — `smtp.gmail.com`, port 587, secure: false
-- **Outlook** — `smtp.office365.com`, port 587, secure: false
-- **Yahoo** — `smtp.mail.yahoo.com`, port 465, secure: true
+- **Gmail** - `smtp.gmail.com`, port 587, secure: false
+- **Outlook** - `smtp.office365.com`, port 587, secure: false
+- **Yahoo** - `smtp.mail.yahoo.com`, port 465, secure: true
diff --git a/lib/services/extractor/botPrevention.js b/lib/services/extractor/botPrevention.js
index e8f0881..2ce4682 100644
--- a/lib/services/extractor/botPrevention.js
+++ b/lib/services/extractor/botPrevention.js
@@ -94,7 +94,7 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver
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
window.chrome = {
runtime: {},
@@ -129,7 +129,7 @@ export async function applyBotPreventionToPage(page, cfg) {
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 mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
const plugin = { name, filename, description, length: 1, 0: mimeObj };
@@ -274,14 +274,14 @@ export async function applyBotPreventionToPage(page, cfg) {
//noop
}
- // document.hasFocus — headless returns false; real active tabs return true
+ // document.hasFocus - headless returns false; real active tabs return true
try {
document.hasFocus = () => true;
} catch {
//noop
}
- // screen color depth — normalise in case headless reports 0
+ // screen color depth - normalise in case headless reports 0
try {
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
diff --git a/lib/services/extractor/puppeteerExtractor.js b/lib/services/extractor/puppeteerExtractor.js
index e6bdd95..d8bf076 100644
--- a/lib/services/extractor/puppeteerExtractor.js
+++ b/lib/services/extractor/puppeteerExtractor.js
@@ -47,7 +47,7 @@ export async function launchBrowser(url, options) {
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 =
options?.executablePath ||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
diff --git a/package.json b/package.json
index 6964359..37745aa 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fredy",
- "version": "21.0.2",
+ "version": "21.1.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -65,6 +65,10 @@
"@douyinfe/semi-icons": "^2.95.1",
"@douyinfe/semi-ui": "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",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6",
@@ -72,17 +76,16 @@
"@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.17",
"better-sqlite3": "^12.9.0",
- "body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
- "cookie-session": "2.1.1",
+ "fastify": "^5.8.5",
"handlebars": "4.7.9",
"maplibre-gl": "^5.24.0",
"nanoid": "5.1.9",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
- "nodemailer": "^8.0.6",
+ "nodemailer": "^8.0.7",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.42.0",
@@ -96,9 +99,7 @@
"react-router": "7.14.2",
"react-router-dom": "7.14.2",
"resend": "^6.12.2",
- "restana": "5.2.0",
"semver": "^7.7.4",
- "serve-static": "2.2.1",
"slack": "11.0.2",
"vite": "8.0.10",
"x-var": "^3.0.1",
diff --git a/test/provider/immobilienDe.test.js b/test/provider/immobilienDe.test.js
index da93607..b128bf8 100644
--- a/test/provider/immobilienDe.test.js
+++ b/test/provider/immobilienDe.test.js
@@ -78,7 +78,7 @@ describe('#immobilien.de testsuite()', () => {
expect(listing.link).toContain('https://www.immobilien.de');
expect(listing.address).toBeTypeOf('string');
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) {
expect(listing.description).toBeTypeOf('string');
}
diff --git a/test/testFixtures/immobilienDe.html b/test/testFixtures/immobilienDe.html
index 8d633e2..20e41f2 100644
--- a/test/testFixtures/immobilienDe.html
+++ b/test/testFixtures/immobilienDe.html
@@ -713,7 +713,7 @@
-