Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot] f2ffea5ba4 chore: bump version to 2.9.8 [skip ci] 2026-04-05 22:09:41 +00:00
jubnl b0dee4dafb feat(mcp): add MCP_MAX_SESSION_PER_USER env var and document it everywhere 2026-04-06 00:09:22 +02:00
github-actions[bot] beb48af8ed chore: bump version to 2.9.7 [skip ci] 2026-04-05 21:38:56 +00:00
jubnl e2be3ec191 fix(atlas): replace fuzzy region matching with exact name_en check
Bidirectional substring matching in isVisitedFeature caused unrelated
regions to be highlighted as visited (e.g. selecting Nordrhein-Westfalen
also marked Nord France due to "nord" being a substring match).

Replace the fuzzy loop with an additional exact check against the Natural
Earth name_en property to cover English-vs-native name mismatches.
Also fix Nominatim field priority to prefer state over county so
reverse-geocoded places resolve to the correct admin-1 level.

Adds integration tests ATLAS-009 through ATLAS-011 covering mark/unmark
region endpoints and user isolation.

Fixes #446
2026-04-05 23:38:34 +02:00
github-actions[bot] 68a1f9683e chore: bump version to 2.9.6 [skip ci] 2026-04-05 21:26:44 +00:00
Maurice 5c57116a68 fix(dayplan): restore time-based auto-sort for places and free reorder for untimed
Timed places now auto-sort chronologically when a time is set.
Untimed places can be freely dragged between timed items.
Transports are inserted by time with per-day position override.
Fixes regression from multi-day spanning PR that removed timed/untimed split.
2026-04-05 23:26:35 +02:00
github-actions[bot] 48508b9df4 chore: bump version to 2.9.5 [skip ci] 2026-04-05 21:12:19 +00:00
jubnl c8250256a7 fix(streaming): end response on client disconnect during asset pipe
When a client disconnects mid-stream, headers are already sent so the
catch block now calls response.end() before returning, preventing the
socket from being left open and crashing the server. Fixes #445.
2026-04-05 23:11:57 +02:00
16 changed files with 242 additions and 26 deletions
+2
View File
@@ -161,6 +161,7 @@ services:
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
@@ -303,6 +304,7 @@ trek.yourdomain.com {
| **Other** | | | | **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
## Optional API Keys ## Optional API Keys
+2
View File
@@ -53,6 +53,8 @@ env:
# Enable demo mode (hourly data resets). # Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60" # MCP_RATE_LIMIT: "60"
# Max MCP API requests per user per minute. Defaults to 60. # Max MCP API requests per user per minute. Defaults to 60.
# MCP_MAX_SESSION_PER_USER: "5"
# Max concurrent MCP sessions per user. Defaults to 5.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.4", "version": "2.9.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.9.4", "version": "2.9.8",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.4", "version": "2.9.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -341,14 +341,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
initTransportPositions(dayId) initTransportPositions(dayId)
} }
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order // All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
// Places keep their order_index ordering — only transports are inserted based on time.
const baseItems = [ const baseItems = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey) ].sort((a, b) => a.sortKey - b.sortKey)
// Only transports are inserted among base items based on time/position // Transports are inserted among places based on time
const timedTransports = transport.map(r => ({ const timedTransports = transport.map(r => ({
type: 'transport' as const, type: 'transport' as const,
data: r, data: r,
@@ -360,22 +359,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return timedTransports.map((item, i) => ({ ...item, sortKey: i })) return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
} }
// Insert transports among base items using persisted position or time-to-position mapping. // Insert transports among places based on per-day position or time
const result = [...baseItems] const result = [...baseItems]
for (let ti = 0; ti < timedTransports.length; ti++) { for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[ti] const timed = timedTransports[ti]
const minutes = timed.minutes const minutes = timed.minutes
// Use per-day position if available, fallback to global position // Use per-day position if explicitly set by user reorder
const dayObj = days.find(d => d.id === dayId)
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)] const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
const effectivePos = perDayPos ?? timed.data.day_plan_position if (perDayPos != null) {
if (effectivePos != null) { result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
result.push({ type: timed.type, sortKey: effectivePos, data: timed.data })
continue continue
} }
// Find insertion position: after the last base item with time <= this transport's time // Find insertion position: after the last place with time <= this transport's time
let insertAfterKey = -Infinity let insertAfterKey = -Infinity
for (const item of result) { for (const item of result) {
if (item.type === 'place') { if (item.type === 'place') {
+6 -7
View File
@@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement {
} }
} }
// Match feature by ISO code OR region name // Match feature by ISO code OR region name (native or English)
const isVisitedFeature = (f: any) => { const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const name = (f.properties?.name || '').toLowerCase() const name = (f.properties?.name || '').toLowerCase()
if (visitedRegionNames.has(name)) return true if (visitedRegionNames.has(name)) return true
// Fuzzy: check if any visited name is contained in feature name or vice versa const nameEn = (f.properties?.name_en || '').toLowerCase()
for (const vn of visitedRegionNames) { if (nameEn && visitedRegionNames.has(nameEn)) return true
if (name.includes(vn) || vn.includes(name)) return true
}
return false return false
} }
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
}, },
onEachFeature: (feature, layer) => { onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || '' const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || '' const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || '' const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature) const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0 const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0
layer.on('click', () => { layer.on('click', () => {
if (!countryA2) return if (!countryA2) return
if (visited) { if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode) const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
if (regionEntry?.manuallyMarked) { if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({ setConfirmActionRef.current({
type: 'unmark-region', type: 'unmark-region',
+1
View File
@@ -39,6 +39,7 @@ services:
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
+1
View File
@@ -29,6 +29,7 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
DEMO_MODE=false # Demo mode - resets data hourly DEMO_MODE=false # Demo mode - resets data hourly
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
# Initial admin account — only used on first boot when no users exist yet. # Initial admin account — only used on first boot when no users exist yet.
# If both are set the admin account is created with these credentials. # If both are set the admin account is created with these credentials.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.4", "version": "2.9.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.9.4", "version": "2.9.8",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.4", "version": "2.9.8",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+2 -1
View File
@@ -18,7 +18,8 @@ interface McpSession {
const sessions = new Map<string, McpSession>(); const sessions = new Map<string, McpSession>();
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_SESSIONS_PER_USER = 5; const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? ""); const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
+28
View File
@@ -168,6 +168,34 @@ export function getParticipants(assignmentId: string | number) {
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) { export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?') db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(placeTime ?? null, endTime ?? null, id); .run(placeTime ?? null, endTime ?? null, id);
// Auto-sort: reorder timed assignments chronologically within the day
if (placeTime) {
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
if (assignment) {
const dayAssignments = db.prepare(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
FROM day_assignments da
JOIN places p ON da.place_id = p.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
// Separate timed and untimed, sort timed by time
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
return ta.localeCompare(tb);
});
const untimed = dayAssignments.filter(a => !a.effective_time);
// Interleave: timed in chronological order, untimed keep relative position
const reordered = [...timed, ...untimed];
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
reordered.forEach((a, i) => update.run(i, a.id));
}
}
return getAssignmentWithPlace(Number(id)); return getAssignmentWithPlace(Number(id));
} }
+1 -1
View File
@@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) { if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
regionCode = regionCode.replace(/[A-Z]$/i, ''); regionCode = regionCode.replace(/[A-Z]$/i, '');
} }
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null; const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
if (!countryCode || !regionName) { regionCache.set(key, null); return null; } if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
const info: RegionInfo = { const info: RegionInfo = {
country_code: countryCode, country_code: countryCode,
@@ -178,7 +178,10 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
await pipeline(Readable.fromWeb(resp.body as any), response); await pipeline(Readable.fromWeb(resp.body as any), response);
} }
} catch (error) { } catch (error) {
if (response.headersSent) return; if (response.headersSent) {
response.end();
return;
}
if (error instanceof SsrfBlockedError) { if (error instanceof SsrfBlockedError) {
response.status(400).json({ error: error.message }); response.status(400).json({ error: error.message });
} else { } else {
+181
View File
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe('Mark/unmark region', () => {
it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ country_code: 'DE' });
expect(res.status).toBe(400);
});
it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen' });
expect(res.status).toBe(400);
});
it('ATLAS-009 — marking a region also auto-marks the parent country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-009 — marking the same region twice is idempotent', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
expect(res.status).toBe(200);
});
it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.post('/api/addons/atlas/region/DE-BY/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Bayern', country_code: 'DE' });
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('regions');
const deRegions = res.body.regions['DE'] as any[];
expect(deRegions).toBeDefined();
const codes = deRegions.map((r: any) => r.code);
expect(codes).toContain('DE-NW');
expect(codes).toContain('DE-BY');
});
it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const del = await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user.id));
const deRegions = res.body.regions['DE'] as any[] | undefined;
const codes = (deRegions || []).map((r: any) => r.code);
expect(codes).not.toContain('DE-NW');
});
it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).not.toContain('DE');
});
it('ATLAS-011 — unmark one region keeps country when another region remains', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.post('/api/addons/atlas/region/DE-BY/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Bayern', country_code: 'DE' });
await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-011 — regions are isolated between users', async () => {
const { user: user1 } = createUser(testDb);
const { user: user2 } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user1.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user2.id));
expect(res.status).toBe(200);
const deRegions = res.body.regions['DE'] as any[] | undefined;
expect(deRegions).toBeUndefined();
});
});
+1
View File
@@ -58,4 +58,5 @@
<!-- Other --> <!-- Other -->
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config> <Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config> <Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
<Config Name="MCP_MAX_SESSION_PER_USER" Target="MCP_MAX_SESSION_PER_USER" Default="5" Mode="" Description="Max concurrent MCP sessions per user." Type="Variable" Display="advanced" Required="false" Mask="false">5</Config>
</Container> </Container>