fix(naver-import): address PR #495 review issues

- SSRF: validate user-supplied URLs with checkSsrf() before fetch in
  both importNaverList and importGoogleList; upgrade naver.me substring
  check to exact hostname comparison to prevent bypass
- i18n: add missing places.importNaverList key to de.ts and es.ts
- migration: switch Naver addon seed to INSERT OR IGNORE to preserve
  admin customizations on re-runs; restore budget_category_order
  CREATE TABLE to its original formatting
- route: remove redundant cast after type-narrowing guard in naver-list handler
- component: hoist provider ternary above try/catch in handleListImport
- tests: add four new Naver import cases (502, empty list, no-coords,
  canonical URL skipping redirect fetch)
This commit is contained in:
jubnl
2026-04-15 04:48:39 +02:00
parent 4362406e74
commit 9789c51d4f
7 changed files with 125 additions and 48 deletions
+8 -41
View File
@@ -868,39 +868,20 @@ function runMigrations(db: Database.Database): void {
// Migration: Budget category ordering
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS budget_category_order
(
trip_id
INTEGER
NOT
NULL
REFERENCES
trips
(
id
) ON DELETE CASCADE,
CREATE TABLE IF NOT EXISTS budget_category_order (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY
(
trip_id,
category
)
);
PRIMARY KEY (trip_id, category)
);
`);
// Seed existing categories with alphabetical order
const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as {
trip_id: number;
category: string
}[];
const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as { trip_id: number; category: string }[];
const ins = db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
let lastTripId = -1;
let idx = 0;
for (const r of rows) {
if (r.trip_id !== lastTripId) {
lastTripId = r.trip_id;
idx = 0;
}
if (r.trip_id !== lastTripId) { lastTripId = r.trip_id; idx = 0; }
ins.run(r.trip_id, r.category, idx++);
}
},
@@ -908,23 +889,9 @@ function runMigrations(db: Database.Database): void {
() => {
try {
db.prepare(`
INSERT INTO addons (id, name, description, type, icon, enabled, sort_order)
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
type = excluded.type,
icon = excluded.icon,
sort_order = excluded.sort_order
`).run(
'naver_list_import',
'Naver List Import',
'Import places from shared Naver Maps lists',
'trip',
'Link2',
0,
13,
);
`).run('naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13);
} catch (err: any) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
+2 -4
View File
@@ -123,10 +123,8 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
return res.status(result.status).json({ error: result.error });
}
const successResult = result as { places: any[]; listName: string };
res.status(201).json({ places: successResult.places, count: successResult.places.length, listName: successResult.listName });
for (const place of successResult.places) {
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
} catch (err: unknown) {
+12 -1
View File
@@ -1,6 +1,7 @@
import { XMLParser } from 'fast-xml-parser';
import { db, getPlaceWithTags } from '../db/database';
import { loadTagsByPlaceIds } from './queryHelpers';
import { checkSsrf } from '../utils/ssrfGuard';
import { Place } from '../types';
interface PlaceWithCategory extends Place {
@@ -309,6 +310,10 @@ export async function importGoogleList(tripId: string, url: string) {
let listId: string | null = null;
let resolvedUrl = url;
// SSRF guard: validate user-supplied URL before fetching
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
if (url.includes('goo.gl') || url.includes('maps.app')) {
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
@@ -416,8 +421,14 @@ export async function importNaverList(
let resolvedUrl = url;
const limit = 20;
// SSRF guard: validate user-supplied URL before fetching
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 };
// Resolve naver.me short links to the canonical map.naver.com folder URL.
if (url.includes('naver.me')) {
let parsedUrl: URL;
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
if (parsedUrl.hostname === 'naver.me') {
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
resolvedUrl = redirectRes.url;
}