mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
Merge remote-tracking branch 'origin/dev' into feat/storage-driver
# Conflicts: # server/src/db/migrations.ts # server/src/nest/journey/journey.controller.ts # server/tests/integration/leg-mode-incoming.test.ts # server/tests/unit/nest/journey.controller.test.ts # server/vitest.config.ts
This commit is contained in:
@@ -3909,13 +3909,89 @@ function runMigrations(db: Database.Database): void {
|
||||
)
|
||||
`);
|
||||
},
|
||||
// #1939 — the Google Places and Unsplash keys are instance configuration and
|
||||
// now resolve out of app_settings (nest/settings/instance-api-keys.ts). This
|
||||
// carries the value the old resolver would have handed out — the lowest-id
|
||||
// admin who has one — into that row, so an install that upgrades keeps the
|
||||
// key it was searching with instead of falling back to OpenStreetMap.
|
||||
// Only where that key already was the whole install's, though. The resolver
|
||||
// reads the instance row before the caller's own column, so promoting a
|
||||
// personal key while a second row still holds one would move that member
|
||||
// onto a stranger's key and a stranger's bill without anyone saying so. As
|
||||
// soon as another row has a value for the same column nothing is written:
|
||||
// everybody keeps resolving what they resolved before, and a member with no
|
||||
// key of their own searches via OpenStreetMap until an admin saves the
|
||||
// instance key once in the panel. Handing a key to the whole instance is the
|
||||
// admin's call, not a migration's.
|
||||
// The users columns are left alone: they are still the per-user fallback,
|
||||
// and nothing here can lose a value. Stored blobs are copied verbatim; both
|
||||
// sides use the same apiKeyCrypto format, legacy plaintext included.
|
||||
// Appended LAST — the array is index-addressed against schema_version.
|
||||
() => {
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
);
|
||||
for (const column of ['maps_api_key', 'unsplash_api_key'] as const) {
|
||||
const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as
|
||||
| { value: string | null }
|
||||
| undefined;
|
||||
if (existing?.value) continue;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, ${column} AS value FROM users
|
||||
WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != ''
|
||||
ORDER BY id ASC LIMIT 1`
|
||||
)
|
||||
.get() as { id: number; value: string } | undefined;
|
||||
if (!row?.value) continue;
|
||||
const otherHolder = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM users
|
||||
WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`
|
||||
)
|
||||
.get(row.id);
|
||||
if (otherHolder) continue;
|
||||
upsert.run(column, row.value);
|
||||
}
|
||||
},
|
||||
// Capture metadata on the photo itself (#1614): when it was taken and where.
|
||||
// Both are needed before photos can sit on the Journey map by their own
|
||||
// coordinates, and before a gallery can be ordered by when a picture was
|
||||
// taken rather than when it happened to be added. Nullable throughout —
|
||||
// most providers answer with neither, and a photo without them is normal.
|
||||
// Guarded so re-running the migration tail is a no-op.
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>;
|
||||
const has = (name: string) => cols.some(c => c.name === name);
|
||||
if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT');
|
||||
if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL');
|
||||
if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL');
|
||||
// Answering "which photos of this journey have coordinates" without a scan.
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL');
|
||||
},
|
||||
// A journey shared while the trip is still running reads like a blog, and a
|
||||
// blog puts the newest entry first (#1614). The owner decides per share link,
|
||||
// because it is a property of how the link is meant to be read, not of the
|
||||
// journey. Off by default: an already-published link must not reorder itself
|
||||
// under its readers.
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'newest_first')) {
|
||||
db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
// Storage slice 2 — collab note attachments historically stored 'files/<name>'
|
||||
// in trip_files.filename while the file manager stored bare names in the same
|
||||
// column; the storage layer addresses objects as category + bare name, so
|
||||
// normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of
|
||||
// 'files/'. The LIKE guard makes it a no-op on already-bare rows.
|
||||
// Appended LAST: the array is index-addressed against schema_version, so a
|
||||
// slot inserted above this line never runs on an existing database.
|
||||
// Appended LAST (again): this slot moved below the three dev slots above in
|
||||
// the dev merge — the array is index-addressed against schema_version, and
|
||||
// the dev slots keep the indices they shipped with. A database that already
|
||||
// ran this migration at its pre-merge index replays it harmlessly (the LIKE
|
||||
// guard) but SKIPS the #1939 key promotion — acceptable only because this
|
||||
// branch was never published; do not repeat this with a released slot.
|
||||
() => {
|
||||
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
|
||||
},
|
||||
|
||||
@@ -27,6 +27,19 @@ function resetDemoUser(): void {
|
||||
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// The Places/Unsplash keys the searches actually use live in app_settings
|
||||
// since #1939, so they have to survive the restore alongside the columns —
|
||||
// otherwise the demo instance loses map search on every reset.
|
||||
interface InstanceKeyRow { key: string; value: string | null }
|
||||
let instanceKeys: InstanceKeyRow[] = [];
|
||||
try {
|
||||
instanceKeys = db.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key')"
|
||||
).all() as InstanceKeyRow[];
|
||||
} catch (e: unknown) {
|
||||
console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// Flush WAL to main DB file
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
|
||||
@@ -67,6 +80,19 @@ function resetDemoUser(): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceKeys.length) {
|
||||
try {
|
||||
const { db: freshDb } = require('../db/database');
|
||||
const upsert = freshDb.prepare(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
);
|
||||
for (const row of instanceKeys) upsert.run(row.key, row.value);
|
||||
} catch (e: unknown) {
|
||||
console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Demo Reset] Database restored from baseline');
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,35 @@ export class AssignmentsMcp {
|
||||
return ok({ assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_leg_transport_mode',
|
||||
description: 'Set the travel mode of a route leg for a place assignment. Use direction "outgoing" (default) for the common case: the leg leaving this stop toward the next. Use direction "incoming" ONLY when this stop\'s arriving leg originates from something that is not itself a place assignment (e.g. a flight/train booking arrival, or a morning hotel departure) – setting "incoming" on an ordinary place-to-place leg is stored but has no effect on route rendering, because it targets a column that is only read for non-place origins. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears it so the leg inherits the day default.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to inherit the day default'),
|
||||
direction: z.enum(['outgoing', 'incoming']).default('outgoing').describe('Which leg to set: "outgoing" (leaving this stop, default) or "incoming" (arriving at it)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async setLegTransportMode(
|
||||
{ tripId, assignmentId, transport_mode, direction }: {
|
||||
tripId: number; assignmentId: number; transport_mode?: string | null; direction?: 'outgoing' | 'incoming';
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
const assignment = direction === 'incoming'
|
||||
? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
|
||||
: this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'move_assignment',
|
||||
description: 'Move a place assignment to a different day.',
|
||||
|
||||
@@ -14,8 +14,19 @@ import {
|
||||
} from './atlas-geo';
|
||||
import { KNOWN_COUNTRIES } from './known-countries';
|
||||
import { cityFromAddress } from './city-from-address';
|
||||
import { transferEndpointIds } from './transfer-endpoints';
|
||||
import type { FlightEndpointRow } from './transfer-endpoints';
|
||||
import { haversineKm } from '../common/geo';
|
||||
|
||||
/**
|
||||
* A reservation endpoint plus the two booking columns that decide whether it may
|
||||
* take part in layover pairing at all (see transfer-endpoints.ts).
|
||||
*/
|
||||
type EndpointRow = FlightEndpointRow & {
|
||||
reservation_type: string | null;
|
||||
reservation_status: string | null;
|
||||
};
|
||||
|
||||
export type CreateBucketData = {
|
||||
name: string;
|
||||
lat?: number | null;
|
||||
@@ -314,19 +325,33 @@ export class AtlasService {
|
||||
// via resolvePlaceCountries above and would otherwise be missed (#1366).
|
||||
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited.
|
||||
const endpoints = this.db
|
||||
const endpointRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT r.trip_id, e.lat, e.lng
|
||||
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
|
||||
r.type AS reservation_type, r.status AS reservation_status,
|
||||
CASE e.role
|
||||
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
|
||||
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
|
||||
END AS fallback_time
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to')
|
||||
`,
|
||||
)
|
||||
.all(...tripIds) as { trip_id: number; lat: number; lng: number }[];
|
||||
.all(...tripIds) as EndpointRow[];
|
||||
|
||||
// Selecting trip_id makes the DISTINCT per trip, so the same airport comes back once
|
||||
// per booking. Collapse to one entry per coordinate before resolving countries —
|
||||
// A layover the traveler never left is only marked 'stop' while both its legs sit in
|
||||
// one booking. Split over two bookings it is a plain 'to' plus 'from' at the same
|
||||
// airport, so it has to be recognised from the times instead (#1535). Only flights
|
||||
// pair: a rental car picked up where a flight landed has its own from/to there and
|
||||
// must keep contributing its country (#1366).
|
||||
const transfers = transferEndpointIds(
|
||||
endpointRows.filter((e) => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled'),
|
||||
);
|
||||
const endpoints = endpointRows.filter((e) => !transfers.has(e.id));
|
||||
|
||||
// Collapse to one entry per coordinate before resolving countries —
|
||||
// getCountryFromCoords is a point-in-polygon scan and used to run once per point.
|
||||
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus }>();
|
||||
for (const e of endpoints) {
|
||||
@@ -962,9 +987,16 @@ export class AtlasService {
|
||||
// planned place there) was never counted as visited (#1366). Resolve each endpoint
|
||||
// coordinate to a country and fold it in too.
|
||||
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
|
||||
const endpoints = this.db.all<{ lat: number; lng: number }>(`
|
||||
SELECT DISTINCT e.lat, e.lng
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited (#1486),
|
||||
// and the same layover split over two bookings is recognised from its ground time
|
||||
// instead, because there it is stored as a legitimate 'to'/'from' pair (#1535).
|
||||
const endpointRows = this.db.all<EndpointRow>(`
|
||||
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
|
||||
r.type AS reservation_type, r.status AS reservation_status,
|
||||
CASE e.role
|
||||
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
|
||||
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
|
||||
END AS fallback_time
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
JOIN trips t ON r.trip_id = t.id
|
||||
@@ -973,7 +1005,18 @@ export class AtlasService {
|
||||
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
|
||||
AND COALESCE(t.start_date, t.end_date) <= date('now')
|
||||
`, userId, userId);
|
||||
for (const e of endpoints) {
|
||||
const transfers = transferEndpointIds(
|
||||
endpointRows.filter(e => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled')
|
||||
);
|
||||
// The DISTINCT no longer collapses per coordinate now that the endpoint id has to be
|
||||
// in the projection, so collapse here instead: getCountryFromCoords is a
|
||||
// point-in-polygon scan and must stay at one run per coordinate.
|
||||
const endpointCoords = new Map<string, { lat: number; lng: number }>();
|
||||
for (const e of endpointRows) {
|
||||
if (transfers.has(e.id)) continue;
|
||||
endpointCoords.set(`${e.lat},${e.lng}`, { lat: e.lat, lng: e.lng });
|
||||
}
|
||||
for (const e of endpointCoords.values()) {
|
||||
const code = getCountryFromCoords(e.lat, e.lng);
|
||||
if (code) countryCodes.add(code.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Layover detection for the two country-deriving Atlas queries.
|
||||
*
|
||||
* A plane change is not a visit, so #1486 taught both queries to skip
|
||||
* reservation_endpoints rows with role='stop'. That only reaches a layover
|
||||
* stored INSIDE one booking. When the legs of one journey land in the database as
|
||||
* two separate reservations (an AirTrail import whose flights never chained, or
|
||||
* legs imported one by one before the join feature existed), the hub is the
|
||||
* legitimate 'to' of the first booking and the legitimate 'from' of the second,
|
||||
* and no role filter can tell that apart from a real destination (#1535).
|
||||
*
|
||||
* What does tell it apart is the time on the ground: leaving the same airport
|
||||
* again within 24 h is a connection, not a stay. The window is deliberately the
|
||||
* one both importers already apply when they merge the legs of one journey
|
||||
* (orderConnectionChain in airtrail-import.service.ts, kitinerary-mapper.ts), so
|
||||
* Atlas and the importers agree on what counts as one connection.
|
||||
*
|
||||
* Read-side only, hence no migration: both queries derive their countries live on
|
||||
* every request, so a database that already holds the split bookings heals as
|
||||
* soon as this ships.
|
||||
*/
|
||||
import { haversineKm } from '../common/geo';
|
||||
|
||||
/** Ground time up to which an onward departure is a connection rather than a stay. */
|
||||
export const MAX_LAYOVER_MS = 24 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* Without clocks only calendar dates are left. Leaving the day after arriving is
|
||||
* still an overnight connection; a wider gap can't be told apart from a genuine
|
||||
* stopover in the stored data, so it keeps counting as visited.
|
||||
*/
|
||||
export const MAX_CLOCKLESS_DAY_GAP = 1;
|
||||
|
||||
/**
|
||||
* How far from where the arriving leg started the departing leg may land and
|
||||
* still count as the flight home rather than the next hop. A return is regularly
|
||||
* booked into the sibling airport of the same city (Charleroi instead of
|
||||
* Zaventem, Gatwick instead of Heathrow), and that day was spent at the
|
||||
* destination, not in transit.
|
||||
*
|
||||
* A radius is the right shape for that question: the country of a coordinate
|
||||
* would be both too coarse (Belfast to Dublin to London would read as a return
|
||||
* and start counting Ireland as visited) and too expensive, since
|
||||
* getCountryFromCoords scans admin polygons and this runs inside the pairing
|
||||
* loop. 100 km covers every sibling pair worth naming (CRL/BRU 45 km, LGW/LHR
|
||||
* 40 km, EWR/JFK 34 km, HND/NRT 60 km) and stays below the distance between two
|
||||
* cities that each own their traffic, so Amsterdam to Frankfurt to Brussels
|
||||
* (170 km) keeps pairing as the connection it is.
|
||||
*/
|
||||
const RETURN_RADIUS_KM = 100;
|
||||
|
||||
/** One reservation_endpoints row, joined to the columns its booking contributes. */
|
||||
export interface FlightEndpointRow {
|
||||
id: number;
|
||||
reservation_id: number;
|
||||
trip_id: number;
|
||||
role: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
local_date: string | null;
|
||||
local_time: string | null;
|
||||
/** The booking's own time column for this side of the leg, used for the date only. */
|
||||
fallback_time: string | null;
|
||||
}
|
||||
|
||||
/** Local calendar date plus, when the leg carried an instant, the local clock. */
|
||||
interface Stamp {
|
||||
date: string | null;
|
||||
time: string | null;
|
||||
}
|
||||
|
||||
/** Where one end of a booking actually is, for the airports the importer geocoded. */
|
||||
interface Coords {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/** The match keys and positions of one booking's own two ends, shared by all of its endpoints. */
|
||||
interface BookingKeys {
|
||||
from: Set<string>;
|
||||
to: Set<string>;
|
||||
fromCoords: Coords[];
|
||||
toCoords: Coords[];
|
||||
}
|
||||
|
||||
interface Endpoint {
|
||||
row: FlightEndpointRow;
|
||||
booking: BookingKeys;
|
||||
/** The buckets this endpoint belongs to, already scoped to its trip. */
|
||||
keys: string[];
|
||||
/** Midnight of the leg's local date. */
|
||||
dayMs: number;
|
||||
/** The leg's exact instant, null when it carried no clock. */
|
||||
exactMs: number | null;
|
||||
/** What a bucket of departures is ordered by: the instant if there is one, else the day. */
|
||||
sortMs: number;
|
||||
}
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
|
||||
const CLOCK = /^\d{2}:\d{2}/;
|
||||
const DAY_MS = 24 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* How far past the arrival's midnight the sorted departures still have to be
|
||||
* walked. Two days, because each half of a pair is anchored on its own date: a
|
||||
* clockless arrival still pairs with a departure at 23:45 the next day, and an
|
||||
* arrival at 23:50 still pairs with a date-only departure the day after. This
|
||||
* bounds the scan only, isConnection stays the one place that decides.
|
||||
*/
|
||||
const SCAN_WINDOW_MS = 2 * DAY_MS;
|
||||
|
||||
/**
|
||||
* Every key an endpoint can be matched on. Two rows for one airport need not
|
||||
* agree on their shape: an imported leg carries the IATA/ICAO code, a booking
|
||||
* typed in by hand may only have been geocoded. Indexing both and pairing on
|
||||
* either match covers the mixed case, and the coordinate key is rounded because
|
||||
* two geocodes of the same airport differ in the last decimals.
|
||||
*/
|
||||
function endpointKeys(e: FlightEndpointRow): string[] {
|
||||
const keys: string[] = [];
|
||||
const code = e.code?.trim();
|
||||
if (code) keys.push(`c:${code.toUpperCase()}`);
|
||||
if (Number.isFinite(e.lat) && Number.isFinite(e.lng)) keys.push(`p:${e.lat.toFixed(2)},${e.lng.toFixed(2)}`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* An endpoint only has local_date/local_time when its leg carried a usable
|
||||
* instant; localParts() returns nulls otherwise, which is exactly the date-only
|
||||
* AirTrail flight that fails to chain in the first place. So fall back to the
|
||||
* booking's own time column, but take its DATE half only: for an arrival that
|
||||
* column can hold the departure airport's local clock (airtrail.mapper.ts), which
|
||||
* says nothing about the time on the ground here.
|
||||
*/
|
||||
function stampOf(e: FlightEndpointRow): Stamp {
|
||||
if (e.local_date && DATE_ONLY.test(e.local_date)) {
|
||||
const time = e.local_time && CLOCK.test(e.local_time) ? e.local_time.slice(0, 5) : null;
|
||||
return { date: e.local_date, time };
|
||||
}
|
||||
const fallback = e.fallback_time?.match(DATE_PREFIX);
|
||||
return { date: fallback ? fallback[1] : null, time: null };
|
||||
}
|
||||
|
||||
function utcMs(date: string, time: string | null): number {
|
||||
return Date.parse(`${date}T${time ?? '00:00'}:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instants the pairing compares, read once per endpoint instead of once per
|
||||
* pair. null means the leg can never be either half of a pair: without a date
|
||||
* that parses, neither branch below has anything to measure.
|
||||
*
|
||||
* A clock that got past the shape check can still be nonsense, and the resulting
|
||||
* NaN is kept rather than downgraded to "no clock": a pair where both sides claim
|
||||
* an instant has always been judged on the instant alone.
|
||||
*/
|
||||
function instantsOf(stamp: Stamp): { dayMs: number; exactMs: number | null; sortMs: number } | null {
|
||||
if (!stamp.date) return null;
|
||||
const dayMs = utcMs(stamp.date, null);
|
||||
if (!Number.isFinite(dayMs)) return null;
|
||||
const exactMs = stamp.time === null ? null : utcMs(stamp.date, stamp.time);
|
||||
return { dayMs, exactMs, sortMs: exactMs !== null && Number.isFinite(exactMs) ? exactMs : dayMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the departure the onward leg of the arrival? Both endpoints sit at the same
|
||||
* airport and therefore in the same timezone, so the naive local stamps compare
|
||||
* as real instants and their difference is the true time on the ground. That is
|
||||
* the only reason this is sound, so never reuse it across two airports.
|
||||
*/
|
||||
function isConnection(arrival: Endpoint, departure: Endpoint): boolean {
|
||||
if (arrival.exactMs !== null && departure.exactMs !== null) {
|
||||
const gap = departure.exactMs - arrival.exactMs;
|
||||
return Number.isFinite(gap) && gap >= 0 && gap <= MAX_LAYOVER_MS;
|
||||
}
|
||||
const days = (departure.dayMs - arrival.dayMs) / DAY_MS;
|
||||
return days >= 0 && days <= MAX_CLOCKLESS_DAY_GAP;
|
||||
}
|
||||
|
||||
function intersects(a: Set<string>, b: Set<string>): boolean {
|
||||
for (const key of a) {
|
||||
if (b.has(key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the departing booking heading back to where the arriving one started? Then
|
||||
* the traveler spent the time in between at the destination, and the same call is
|
||||
* the one orderConnectionChain makes before it joins two legs.
|
||||
*
|
||||
* The airport keys answer that for a return into the exact same airport; the
|
||||
* distance covers the sibling airport of the same city, which the keys can never
|
||||
* match. Legs the importer had no coordinates for fall back to the key match.
|
||||
*/
|
||||
function returnsToOrigin(departure: Endpoint, arrival: Endpoint): boolean {
|
||||
if (intersects(departure.booking.to, arrival.booking.from)) return true;
|
||||
for (const landed of departure.booking.toCoords) {
|
||||
for (const started of arrival.booking.fromCoords) {
|
||||
if (haversineKm(landed.lat, landed.lng, started.lat, started.lng) <= RETURN_RADIUS_KM) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** First entry of a bucket sorted by sortMs that is not earlier than `from`. */
|
||||
function firstFrom(bucket: Endpoint[], from: number): number {
|
||||
let low = 0;
|
||||
let high = bucket.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (bucket[mid].sortMs < from) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint ids that are only a connection airport, spread over two bookings.
|
||||
*
|
||||
* Callers pass flight endpoints of non-cancelled bookings only: a car rental
|
||||
* picked up right where a flight landed has its own from/to at that airport and
|
||||
* would otherwise pair with the arrival and swallow the destination country
|
||||
* (#1366).
|
||||
*/
|
||||
export function transferEndpointIds(rows: FlightEndpointRow[]): Set<number> {
|
||||
const transfers = new Set<number>();
|
||||
if (rows.length < 2) return transfers;
|
||||
|
||||
const landings: Endpoint[] = [];
|
||||
const departures = new Map<string, Endpoint[]>();
|
||||
const bookings = new Map<number, BookingKeys>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.role !== 'from' && row.role !== 'to') continue;
|
||||
const keys = endpointKeys(row);
|
||||
if (keys.length === 0) continue;
|
||||
|
||||
let booking = bookings.get(row.reservation_id);
|
||||
if (!booking) {
|
||||
booking = { from: new Set<string>(), to: new Set<string>(), fromCoords: [], toCoords: [] };
|
||||
bookings.set(row.reservation_id, booking);
|
||||
}
|
||||
const arriving = row.role === 'to';
|
||||
const ownKeys = arriving ? booking.to : booking.from;
|
||||
for (const key of keys) ownKeys.add(key);
|
||||
if (Number.isFinite(row.lat) && Number.isFinite(row.lng)) {
|
||||
(arriving ? booking.toCoords : booking.fromCoords).push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
|
||||
// A leg without a readable date pairs with nothing, but its keys and its
|
||||
// position had to be recorded above: they describe the booking either way.
|
||||
const instants = instantsOf(stampOf(row));
|
||||
if (!instants) continue;
|
||||
|
||||
// Two trips that happen to touch the same airport are two journeys: an
|
||||
// arrival home from one and a departure on the next is not a layover. The
|
||||
// trip belongs in the bucket key rather than in a per-pair check, so the
|
||||
// journeys of an imported flight log are never compared against each other.
|
||||
const endpoint: Endpoint = { row, booking, keys: keys.map((key) => `${row.trip_id}:${key}`), ...instants };
|
||||
if (arriving) {
|
||||
landings.push(endpoint);
|
||||
continue;
|
||||
}
|
||||
for (const key of endpoint.keys) {
|
||||
const bucket = departures.get(key);
|
||||
if (bucket) bucket.push(endpoint);
|
||||
else departures.set(key, [endpoint]);
|
||||
}
|
||||
}
|
||||
|
||||
// In time order, so an arrival can stop looking once the ground time is out of
|
||||
// reach. Ten years of flight log imported into one trip put hundreds of
|
||||
// departures on a home airport, and comparing every arrival against all of them
|
||||
// cost more than a second on both request paths that call this.
|
||||
for (const bucket of departures.values()) bucket.sort((a, b) => a.sortMs - b.sortMs);
|
||||
|
||||
for (const arrival of landings) {
|
||||
const until = arrival.dayMs + SCAN_WINDOW_MS;
|
||||
// An endpoint carrying both a code and a coordinate sits in two buckets, so
|
||||
// without this the same pair would be judged twice.
|
||||
const seen = arrival.keys.length > 1 ? new Set<Endpoint>() : null;
|
||||
for (const key of arrival.keys) {
|
||||
const left = departures.get(key);
|
||||
if (!left) continue;
|
||||
for (let i = firstFrom(left, arrival.dayMs); i < left.length; i++) {
|
||||
const departure = left[i];
|
||||
if (departure.sortMs > until) break;
|
||||
if (seen) {
|
||||
if (seen.has(departure)) continue;
|
||||
seen.add(departure);
|
||||
}
|
||||
if (departure.row.reservation_id === arrival.row.reservation_id) continue;
|
||||
if (returnsToOrigin(departure, arrival)) continue;
|
||||
if (!isConnection(arrival, departure)) continue;
|
||||
transfers.add(arrival.row.id);
|
||||
transfers.add(departure.row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transfers;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
'user.mfa_enable': 'enabled MFA',
|
||||
'user.mfa_disable': 'disabled MFA',
|
||||
'settings.app_update': 'updated settings',
|
||||
'settings.api_keys_update': 'updated API keys',
|
||||
'trip.create': 'created trip',
|
||||
'trip.delete': 'deleted trip',
|
||||
'admin.user_role_change': 'changed user role',
|
||||
@@ -38,6 +39,12 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
|
||||
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
|
||||
return parts.length ? ` (${parts.join(', ')})` : '';
|
||||
}
|
||||
if (action === 'settings.api_keys_update') {
|
||||
// The names, read straight out of details — the writer puts nothing else in
|
||||
// there, and a key value must never reach a log line.
|
||||
const changed = Array.isArray(details.changed) ? details.changed : [];
|
||||
return changed.length ? ` (${changed.join(', ')})` : '';
|
||||
}
|
||||
if (action === 'immich.private_ip_configured') {
|
||||
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
|
||||
}
|
||||
|
||||
@@ -117,22 +117,51 @@ export class AuthController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per save that actually changed a key, on all three routes that can
|
||||
* write these columns (#1939 asked for it: the API keys were the only admin
|
||||
* setting whose change left no trace, while `settings.app_update` next door
|
||||
* has always been logged).
|
||||
*
|
||||
* Names only. `details` is rendered as raw JSON in the admin audit panel and
|
||||
* mirrored into the debug log, so a value here would leak the key twice over.
|
||||
* Nothing is written when nothing changed — the panel saves before every test
|
||||
* click, and each click would otherwise cost a log line.
|
||||
*/
|
||||
private auditApiKeys(userId: number, changed: string[], req: Request): void {
|
||||
if (!changed.length) return;
|
||||
this.audit.writeAudit({
|
||||
userId,
|
||||
action: 'settings.api_keys_update',
|
||||
resource: 'api_keys',
|
||||
ip: getClientIp(req),
|
||||
details: { changed },
|
||||
});
|
||||
}
|
||||
|
||||
@Put('me/maps-key')
|
||||
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto) {
|
||||
return this.profile.updateMapsKey(user.id, body.maps_api_key);
|
||||
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
|
||||
// changedKeys is for the audit line, not for the client: destructured off so
|
||||
// the response body stays what it always was.
|
||||
const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
|
||||
this.auditApiKeys(user.id, changedKeys, req);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('me/api-keys')
|
||||
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto) {
|
||||
return this.profile.updateApiKeys(user.id, body);
|
||||
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
|
||||
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
|
||||
this.auditApiKeys(user.id, changedKeys, req);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('me/settings')
|
||||
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto) {
|
||||
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
const result = this.profile.updateSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
return { success: result.success, user: result.user };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { validatePassword } from '../common/passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { resolveApiKey } from '../settings/instance-api-keys';
|
||||
import { EphemeralTokenService } from './ephemeral-token.service';
|
||||
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
|
||||
// the whole tools fan-out (and via the domain bridges, the Nest services) into
|
||||
@@ -236,13 +237,12 @@ export class AuthService {
|
||||
// One directory deeper than the legacy src/services location — the extra
|
||||
// '../' keeps resolving to the workspace package.json.
|
||||
const version: string = readEnv().app.appVersion ?? require('../../../package.json').version;
|
||||
// The operator's key counts too. getMapsKey prefers it over anything in the
|
||||
// users table, so asking the table alone would report "no key" on an install
|
||||
// where every lookup works, and the client would hide the Google features it
|
||||
// is actually able to use.
|
||||
const hasGoogleKey =
|
||||
!!readEnv().maps.placesApiKey ||
|
||||
!!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1");
|
||||
// Asked through the same resolver the search itself uses, so the client can
|
||||
// never show Google features that rest on a key this caller does not get —
|
||||
// nor hide them from a member who does have one (#1939). Unauthenticated the
|
||||
// question is only about the instance, which is the first two steps of the
|
||||
// chain; id 0 matches no row.
|
||||
const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
|
||||
const oidcDisplayName = readEnv().oidc.displayName ||
|
||||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
|
||||
const oidcConfigured = !!(
|
||||
|
||||
@@ -6,6 +6,14 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { EMAIL_REGEX, mask_stored_api_key } from './auth.helpers';
|
||||
import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
|
||||
import {
|
||||
INSTANCE_API_KEY_NAMES,
|
||||
readInstanceApiKey,
|
||||
resolveApiKey,
|
||||
writeInstanceApiKey,
|
||||
type InstanceApiKeyName,
|
||||
} from '../settings/instance-api-keys';
|
||||
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
|
||||
import { User } from '../../types';
|
||||
|
||||
/**
|
||||
@@ -43,33 +51,109 @@ export class UserProfileService {
|
||||
return readEnv().managed.enabled;
|
||||
}
|
||||
|
||||
/** The three key columns plus the role that decides where a save lands. */
|
||||
private currentKeys(userId: number) {
|
||||
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a save is measured against, in cleartext.
|
||||
*
|
||||
* For an admin and one of the two instance-wide names that is the instance
|
||||
* value, because that is what the panel shows them and what the search uses;
|
||||
* their own column only speaks when no instance value has been set yet.
|
||||
*/
|
||||
private storedKeyPlaintext(
|
||||
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
|
||||
isAdmin: boolean,
|
||||
): string {
|
||||
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
|
||||
const instance = readInstanceApiKey(this.db, name as InstanceApiKeyName);
|
||||
if (instance !== null) return instance;
|
||||
}
|
||||
return decrypt_api_key(current?.[name]) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which key names this body actually changes.
|
||||
*
|
||||
* Compared on cleartext, never on what is stored: the IV is random, so the
|
||||
* same key encrypted twice differs every time and a ciphertext comparison
|
||||
* would report a change on every save. The panel saves before each test click,
|
||||
* so that difference is the one between an audit trail and a full log.
|
||||
*
|
||||
* `skipped` are the names a managed install refuses to write — auditing them
|
||||
* would claim a change that never happened.
|
||||
*/
|
||||
private changedKeyNames(
|
||||
body: Record<string, unknown>,
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
|
||||
isAdmin: boolean,
|
||||
skipped: string[] = [],
|
||||
): string[] {
|
||||
const norm = (v: unknown) => String(v ?? '').trim();
|
||||
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
|
||||
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the two instance-wide names into app_settings when an admin saves.
|
||||
*
|
||||
* Only an admin: `PUT /me/api-keys` is not admin-gated (the class carries
|
||||
* JwtAuthGuard alone), so letting any caller write here would hand every
|
||||
* member the instance credential. A non-admin keeps writing their own column,
|
||||
* which is still the last step of the resolver.
|
||||
*/
|
||||
private mirrorInstanceKeys(body: Record<string, unknown>, isAdmin: boolean): void {
|
||||
if (!isAdmin) return;
|
||||
for (const name of INSTANCE_API_KEY_NAMES) {
|
||||
if (body[name] !== undefined) writeInstanceApiKey(this.db, name, body[name]);
|
||||
}
|
||||
}
|
||||
|
||||
updateMapsKey(userId: number, key: unknown) {
|
||||
const maps_api_key = key as string | null | undefined;
|
||||
if (this.managed) {
|
||||
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'] };
|
||||
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] };
|
||||
}
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
maybe_encrypt_api_key(maps_api_key), userId
|
||||
);
|
||||
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) };
|
||||
const current = this.currentKeys(userId);
|
||||
const isAdmin = current?.role === 'admin';
|
||||
const changedKeys = this.changedKeyNames({ maps_api_key }, current, isAdmin);
|
||||
this.db.transaction(() => {
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
maybe_encrypt_api_key(maps_api_key), userId
|
||||
);
|
||||
this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
|
||||
});
|
||||
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
|
||||
}
|
||||
|
||||
updateApiKeys(userId: number, rawBody: unknown) {
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
|
||||
const { blocked } = splitManagedKeys(body, this.managed);
|
||||
for (const key of blocked) delete body[key as keyof typeof body];
|
||||
const current = this.db.get<Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId);
|
||||
const current = this.currentKeys(userId);
|
||||
const isAdmin = current?.role === 'admin';
|
||||
const changedKeys = this.changedKeyNames(body, current, isAdmin, blocked);
|
||||
|
||||
// `?? null` instead of the former non-null assertions: a user row deleted
|
||||
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
|
||||
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
|
||||
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
|
||||
userId
|
||||
);
|
||||
this.db.transaction(() => {
|
||||
// `?? null` instead of the former non-null assertions: a user row deleted
|
||||
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
|
||||
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
|
||||
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
|
||||
userId
|
||||
);
|
||||
this.mirrorInstanceKeys(body, isAdmin);
|
||||
});
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
@@ -81,13 +165,14 @@ export class UserProfileService {
|
||||
success: true,
|
||||
...(blocked.length ? { managed_keys: blocked } : {}),
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
changedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
updateSettings(
|
||||
userId: number,
|
||||
rawBody: unknown
|
||||
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
|
||||
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
|
||||
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
|
||||
|
||||
@@ -126,10 +211,19 @@ export class UserProfileService {
|
||||
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
|
||||
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
|
||||
|
||||
// Read before the write, so the comparison sees the old value; the role in
|
||||
// the same row decides whether the two instance-wide names travel with it.
|
||||
const current = this.currentKeys(userId);
|
||||
const isAdmin = current?.role === 'admin';
|
||||
const changedKeys = keyLocked ? [] : this.changedKeyNames(body, current, isAdmin, blocked);
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(userId);
|
||||
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
|
||||
this.db.transaction(() => {
|
||||
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
|
||||
if (!keyLocked) this.mirrorInstanceKeys(body, isAdmin);
|
||||
});
|
||||
}
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
@@ -142,6 +236,7 @@ export class UserProfileService {
|
||||
success: true,
|
||||
...(blocked.length ? { managed_keys: blocked } : {}),
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
changedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,11 +263,15 @@ export class UserProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
// Maps and Unsplash are read where the search reads them: instance-wide
|
||||
// first. Showing the admin their own column while every request used another
|
||||
// value is the confusion #1939 reported. Their column still answers while no
|
||||
// instance value exists — on that install it is what the resolver picks too.
|
||||
return {
|
||||
settings: {
|
||||
maps_api_key: decrypt_api_key(user.maps_api_key),
|
||||
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
|
||||
openweather_api_key: decrypt_api_key(user.openweather_api_key),
|
||||
unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
|
||||
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -227,7 +326,7 @@ export class UserProfileService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
|
||||
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'>>('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?', userId);
|
||||
const user = this.db.get<Pick<User, 'role' | 'openweather_api_key'>>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
|
||||
|
||||
const result: {
|
||||
@@ -243,7 +342,10 @@ export class UserProfileService {
|
||||
};
|
||||
} = { maps: false, weather: false, maps_details: null };
|
||||
|
||||
const maps_api_key = decrypt_api_key(user.maps_api_key);
|
||||
// The key a search would actually use, not the one in this admin's column:
|
||||
// testing a value nothing resolves to is how "the panel says the key is
|
||||
// fine" and "every search 403s" coexisted (#1939).
|
||||
const { key: maps_api_key } = resolveApiKey(this.db, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
if (maps_api_key) {
|
||||
try {
|
||||
// Same Referer as maps.service googleFetch — without it, keys with an
|
||||
@@ -257,7 +359,9 @@ export class UserProfileService {
|
||||
...(referer ? { Referer: referer } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': maps_api_key,
|
||||
'X-Goog-FieldMask': 'places.displayName',
|
||||
// The mask the real search sends. A narrower probe passes on keys
|
||||
// that are restricted to fewer Places SKUs than TREK asks for.
|
||||
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
|
||||
},
|
||||
body: JSON.stringify({ textQuery: 'test' }),
|
||||
}
|
||||
|
||||
@@ -412,6 +412,14 @@ export class CalendarService {
|
||||
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
|
||||
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
|
||||
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
|
||||
// A segment booked under its own reference gets its own line (#1943): at
|
||||
// the gate it is that code the airline asks for, not the booking's. No
|
||||
// leg reference, no extra line, so an existing feed stays byte-identical.
|
||||
for (const leg of meta.legs as { from?: string; to?: string; confirmation_number?: string }[]) {
|
||||
if (!leg.confirmation_number) continue;
|
||||
const segment = [leg.from, leg.to].filter(Boolean).join('-');
|
||||
desc += `\nConfirmation${segment ? ` ${segment}` : ''}: ${leg.confirmation_number}`;
|
||||
}
|
||||
} else if (meta.departure_airport || meta.arrival_airport) {
|
||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||||
|
||||
@@ -104,6 +104,30 @@ export class DaysMcp {
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_day_default_transport_mode',
|
||||
description: 'Set the whole-day default travel mode for a day. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears the default. Per-segment leg modes still override this.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to clear the day default'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async setDayDefaultTransportMode(
|
||||
{ tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
|
||||
this.guards.safeBroadcast(tripId, 'day:updated', { day });
|
||||
return ok({ day });
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-days',
|
||||
uriTemplate: 'trek://trips/{tripId}/days',
|
||||
|
||||
@@ -14,7 +14,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
const JP_SELECT = `
|
||||
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
|
||||
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
|
||||
tp.media_type, tp.duration_ms
|
||||
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
|
||||
`;
|
||||
|
||||
const JP_JOIN = `journey_entry_photos jep
|
||||
@@ -24,7 +24,7 @@ const JP_JOIN = `journey_entry_photos jep
|
||||
const GALLERY_SELECT = `
|
||||
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
|
||||
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
|
||||
tp.media_type, tp.duration_ms
|
||||
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
|
||||
`;
|
||||
|
||||
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
|
||||
@@ -342,6 +342,10 @@ export class JourneyDomainService {
|
||||
// (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped
|
||||
// path enforces.
|
||||
if (!this.db.canAccessTrip(tripId, userId)) return false;
|
||||
// And a journey the caller can actually reach. Without this, any logged-in user
|
||||
// could link a trip of theirs into a stranger's journey and seed entries and
|
||||
// photos there — the MCP tool has always checked this, the REST route never did.
|
||||
if (!this.canAccessJourney(journeyId, userId)) return false;
|
||||
const now = this.ts();
|
||||
try {
|
||||
this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(
|
||||
@@ -355,8 +359,12 @@ export class JourneyDomainService {
|
||||
|
||||
// sync skeleton entries for all places in this trip
|
||||
this.syncTripPlaces(journeyId, tripId, userId);
|
||||
// import existing trip photos (Immich/Synology) with sharing settings
|
||||
this.syncTripPhotos(journeyId, tripId);
|
||||
// Trip photos are deliberately NOT pulled in any more (#1614). Photos live in
|
||||
// journeys now: the trip-photo surface lost its UI in 3.1.0, nothing writes to
|
||||
// it on an install newer than that, and copying rows between the two was what
|
||||
// let a photo one member had chosen not to share reach a journey at all. The
|
||||
// table and its (unreferenced) routes stay for one more release rather than
|
||||
// being dropped in an append-only migration.
|
||||
this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
|
||||
return true;
|
||||
}
|
||||
@@ -441,29 +449,6 @@ export class JourneyDomainService {
|
||||
}
|
||||
}
|
||||
|
||||
// import trip_photos into journey gallery when a trip is linked
|
||||
private syncTripPhotos(journeyId: number, tripId: number) {
|
||||
const tripPhotos = this.db
|
||||
.prepare('SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?')
|
||||
.all(tripId) as { photo_id: number; shared: number }[];
|
||||
if (!tripPhotos.length) return;
|
||||
|
||||
const now = this.ts();
|
||||
const maxOrderRow = this.db
|
||||
.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?')
|
||||
.get(journeyId) as { m: number | null };
|
||||
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
|
||||
|
||||
for (const tp of tripPhotos) {
|
||||
this.db.prepare(
|
||||
`
|
||||
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is created
|
||||
onPlaceCreated(tripId: number, placeId: number) {
|
||||
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
|
||||
|
||||
@@ -7,6 +7,8 @@ interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
share_gallery?: boolean;
|
||||
share_map?: boolean;
|
||||
/** Read the journey newest-first, like a blog, rather than in trip order. */
|
||||
newest_first?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyShareTokenInfo {
|
||||
@@ -15,6 +17,7 @@ interface JourneyShareTokenInfo {
|
||||
share_timeline: boolean;
|
||||
share_gallery: boolean;
|
||||
share_map: boolean;
|
||||
newest_first: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,18 +48,19 @@ export class JourneyShareService {
|
||||
share_timeline = true,
|
||||
share_gallery = true,
|
||||
share_map = true,
|
||||
newest_first = false,
|
||||
} = permissions;
|
||||
|
||||
const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
|
||||
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
|
||||
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?')
|
||||
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId);
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
|
||||
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map, newest_first) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ export class JourneyShareService {
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
newest_first: !!row.newest_first,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,7 +135,7 @@ export class JourneyShareService {
|
||||
const photos = this.db.prepare(`
|
||||
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
|
||||
tkp.media_type, tkp.duration_ms
|
||||
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
|
||||
FROM journey_entry_photos jep
|
||||
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
|
||||
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||
@@ -146,7 +151,7 @@ export class JourneyShareService {
|
||||
const gallery = this.db.prepare(`
|
||||
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
|
||||
tkp.media_type, tkp.duration_ms
|
||||
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
|
||||
FROM journey_photos gp
|
||||
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||
WHERE gp.journey_id = ?
|
||||
@@ -183,6 +188,7 @@ export class JourneyShareService {
|
||||
const projected: Record<string, unknown> = { ...e };
|
||||
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
|
||||
if (!shareGallery) projected.photos = [];
|
||||
else if (!shareMap) projected.photos = stripPhotoGps(e.photos);
|
||||
return projected;
|
||||
});
|
||||
} else if (shareMap) {
|
||||
@@ -208,13 +214,23 @@ export class JourneyShareService {
|
||||
status: journey.status,
|
||||
},
|
||||
entries: publicEntries,
|
||||
gallery: shareGallery ? gallery : [],
|
||||
// A photo now carries the coordinates it was taken at, which is a location the
|
||||
// owner never typed and may not expect to publish. It follows share_map, the
|
||||
// same switch the entry coordinates follow — otherwise a gallery-only share
|
||||
// would hand out places the map was deliberately turned off for.
|
||||
gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
|
||||
stats,
|
||||
permissions: {
|
||||
share_timeline: shareTimeline,
|
||||
share_gallery: shareGallery,
|
||||
share_map: shareMap,
|
||||
newest_first: !!row.newest_first,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop capture coordinates from a photo list, keeping everything else. */
|
||||
function stripPhotoGps<T>(photos: T[] | undefined | null): T[] {
|
||||
return (photos ?? []).map(p => ({ ...(p as Record<string, unknown>), lat: null, lng: null })) as T[];
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { User } from '../../types';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { journeyThumbName } from '../memories/thumbnail.service';
|
||||
import { JourneyService } from './journey.service';
|
||||
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
@@ -126,6 +127,7 @@ export class JourneyController {
|
||||
constructor(
|
||||
private readonly journey: JourneyService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly captureBackfill: PhotoCaptureBackfillService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -147,6 +149,16 @@ export class JourneyController {
|
||||
}
|
||||
}
|
||||
|
||||
// The add call carries only an asset id, so when and where the picture was taken
|
||||
// are fetched from the provider afterwards rather than trusted from the client
|
||||
// (#1614). Detached: a slow or unreachable provider must not hold up the add.
|
||||
private backfillCapture(photos: unknown[], userId: number): void {
|
||||
const ids = photos
|
||||
.map(p => (p as { photo_id?: number } | null)?.photo_id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
this.captureBackfill.schedule(ids, userId);
|
||||
}
|
||||
|
||||
// ── Static prefix routes (before /:id) ──────────────────────────────────
|
||||
@Get()
|
||||
list(@CurrentUser() user: User) {
|
||||
@@ -226,6 +238,7 @@ export class JourneyController {
|
||||
if (!results.length) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
this.backfillCapture(results, user.id);
|
||||
return { photos: results };
|
||||
}
|
||||
|
||||
@@ -239,6 +252,7 @@ export class JourneyController {
|
||||
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
|
||||
if (photo) added.push(photo);
|
||||
});
|
||||
this.backfillCapture(added, user.id);
|
||||
return { photos: added, added: added.length };
|
||||
}
|
||||
if (!body.provider || !body.asset_id) {
|
||||
@@ -248,6 +262,7 @@ export class JourneyController {
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
|
||||
}
|
||||
this.backfillCapture([photo], user.id);
|
||||
return photo;
|
||||
}
|
||||
|
||||
@@ -332,6 +347,9 @@ export class JourneyController {
|
||||
if (!photos.length) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
// An uploaded file carries its own EXIF; reading it is what puts the photo on
|
||||
// the map later. Detached, like the provider branch.
|
||||
this.backfillCapture(photos, user.id);
|
||||
return { photos };
|
||||
}
|
||||
|
||||
@@ -390,6 +408,7 @@ export class JourneyController {
|
||||
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
|
||||
if (photo) added.push(photo);
|
||||
});
|
||||
this.backfillCapture(added, user.id);
|
||||
return { photos: added, added: added.length };
|
||||
}
|
||||
if (!body.provider || !body.asset_id) {
|
||||
@@ -399,6 +418,7 @@ export class JourneyController {
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
|
||||
}
|
||||
this.backfillCapture([photo], user.id);
|
||||
return photo;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ export function buildUserAgent(instanceUrl: string | undefined): string {
|
||||
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
|
||||
export const UA = buildUserAgent(getAppUrl());
|
||||
|
||||
/**
|
||||
* The fields places:searchText is asked for.
|
||||
*
|
||||
* Here rather than beside the call because the admin panel's key test has to
|
||||
* send the same mask: a key restricted to a narrower set of Places SKUs answers
|
||||
* a one-field probe with 200 and the real search with 403, which is the second
|
||||
* way to reach the #1939 report ("test button green, searching fails").
|
||||
*/
|
||||
export const SEARCH_TEXT_FIELD_MASK =
|
||||
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus';
|
||||
|
||||
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
||||
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
||||
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
||||
@@ -118,17 +129,29 @@ export function namesOverlap(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
|
||||
// The same id inside the `data=` pathname blob, where a resolved share link keeps it:
|
||||
// /maps/place/<name>/data=!4m6!3m5!1s0x47e66e1f06e2b70f:0x40b82c3688c9460!8m2!3d48.85!4d2.29
|
||||
const GOOGLE_FTID_DATA_RE = /!1s(0x[0-9a-f]{1,20}:0x[0-9a-f]{1,20})/i;
|
||||
|
||||
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
|
||||
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL.
|
||||
// Two shapes carry it. The `?ftid=` query parameter is the old one. A share link that
|
||||
// resolveGoogleMapsUrl has followed to its final hop carries it instead in the `data=`
|
||||
// pathname blob, in the same `!1s…!3d…!4d…` run the coordinates come from (#1954) — so
|
||||
// reading only the query parameter dropped the id for every pasted maps.app.goo.gl link.
|
||||
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
|
||||
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
|
||||
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
|
||||
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
|
||||
// with neither, so this still returns null for most API responses — the precise
|
||||
// query_place_id link is used instead.
|
||||
export function googleFtidFromMapsUrl(url?: string | null): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const ftid = new URL(url).searchParams.get('ftid')?.trim();
|
||||
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
|
||||
const parsed = new URL(url);
|
||||
const ftid = parsed.searchParams.get('ftid')?.trim();
|
||||
if (ftid && GOOGLE_FTID_RE.test(ftid)) return ftid.toLowerCase();
|
||||
// Only for a place URL. A /maps/dir/ route carries one !1s per waypoint and the
|
||||
// first one is the origin, not the place the link is about.
|
||||
if (!parsed.pathname.includes('/place/')) return null;
|
||||
const fromPath = GOOGLE_FTID_DATA_RE.exec(parsed.pathname)?.[1];
|
||||
return fromPath ? fromPath.toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import type {
|
||||
} from '@trek/shared';
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
|
||||
import {
|
||||
UA,
|
||||
SEARCH_TEXT_FIELD_MASK,
|
||||
toApiLang,
|
||||
googleFtidFromMapsUrl,
|
||||
buildOsmDetails,
|
||||
@@ -59,6 +60,18 @@ function placesEndpoint(endpoint: string): string {
|
||||
return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Says which of the three credentials Google rejected, never which value.
|
||||
*
|
||||
* The response body Google sends ("The caller does not have permission") is
|
||||
* identical whichever key was used, so without this line a report of "works for
|
||||
* the admin, fails for everyone else" cannot be told apart from a genuinely
|
||||
* broken key.
|
||||
*/
|
||||
function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void {
|
||||
console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
|
||||
}
|
||||
|
||||
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
const endpoint = placesEndpoint(rawEndpoint);
|
||||
googleApiCallCount++;
|
||||
@@ -548,25 +561,25 @@ export class MapsService {
|
||||
|
||||
// ── API key retrieval ──────────────────────────────────────────────────────
|
||||
|
||||
getMapsKey(userId: number): string | null {
|
||||
// When the operator supplies the credential, the database is not asked at
|
||||
// all. Two reasons, and the second is the load-bearing one: a per-user key
|
||||
// would route around whatever the operator's endpoint counts, and the key
|
||||
// columns are readable by any admin of this instance, so a shared key stored
|
||||
// there would be a key handed out. Unset, this branch never runs.
|
||||
const operatorKey = readEnv().maps.placesApiKey;
|
||||
if (operatorKey) return operatorKey;
|
||||
/**
|
||||
* The Places credential for this request, and where it came from.
|
||||
*
|
||||
* Operator env first: a per-user key would route around whatever the
|
||||
* operator's endpoint counts, and unset, that branch never runs. Then the
|
||||
* instance-wide value the admin panel writes, then the caller's own row.
|
||||
*
|
||||
* What is deliberately gone is the old third step, "any admin's key" (#1939):
|
||||
* it read a stranger's credential, which server/CLAUDE.md forbids, and made
|
||||
* the answer depend on who was asking — the saving admin got their own key,
|
||||
* everybody else got the lowest-id admin's and a 403 from Google. The source
|
||||
* is returned so a provider error can say which of the three was used.
|
||||
*/
|
||||
resolveMapsKey(userId: number): { key: string | null; source: ApiKeySource | null } {
|
||||
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
}
|
||||
|
||||
const user = this.database.get<{ maps_api_key: string | null }>(
|
||||
'SELECT maps_api_key FROM users WHERE id = ?',
|
||||
userId,
|
||||
);
|
||||
const user_key = decrypt_api_key(user?.maps_api_key);
|
||||
if (user_key) return user_key;
|
||||
const admin = this.database.get<{ maps_api_key: string }>(
|
||||
"SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
|
||||
);
|
||||
return decrypt_api_key(admin?.maps_api_key) || null;
|
||||
getMapsKey(userId: number): string | null {
|
||||
return this.resolveMapsKey(userId).key;
|
||||
}
|
||||
|
||||
// ── Nominatim search ───────────────────────────────────────────────────────
|
||||
@@ -1417,7 +1430,7 @@ export class MapsService {
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const apiKey = this.getMapsKey(userId);
|
||||
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
const places = await this.searchNominatim(query, lang);
|
||||
@@ -1441,8 +1454,7 @@ export class MapsService {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask':
|
||||
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
|
||||
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
|
||||
},
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
@@ -1450,6 +1462,7 @@ export class MapsService {
|
||||
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
logKeyFailure('searchText', response.status, userId, keySource);
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
@@ -1488,7 +1501,7 @@ export class MapsService {
|
||||
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
|
||||
sessionToken?: string,
|
||||
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||
const apiKey = this.getMapsKey(userId);
|
||||
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
return this.autocompleteNominatim(input, lang);
|
||||
@@ -1526,6 +1539,7 @@ export class MapsService {
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
logKeyFailure('autocomplete', response.status, userId, keySource);
|
||||
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
@@ -1960,6 +1974,7 @@ export class MapsService {
|
||||
lat: string,
|
||||
lng: string,
|
||||
lang?: string,
|
||||
opts?: { lane?: GeoLane; timeoutMs?: number },
|
||||
): Promise<{ name: string | null; address: string | null }> {
|
||||
const params = new URLSearchParams({
|
||||
lat,
|
||||
@@ -1969,7 +1984,7 @@ export class MapsService {
|
||||
zoom: '18',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
const response = await nominatimFetch('reverse', params);
|
||||
const response = await nominatimFetch('reverse', params, opts);
|
||||
if (!response.ok) return { name: null, address: null };
|
||||
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
|
||||
const addr = data.address || {};
|
||||
|
||||
@@ -522,6 +522,11 @@ export class ImmichService {
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
// The search path has always carried these; dropping them here meant a
|
||||
// photo picked out of an album could never be placed on a map, and the
|
||||
// distance sort silently had nothing to sort by (#1614).
|
||||
lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
|
||||
lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
|
||||
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
|
||||
}));
|
||||
return { assets };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ImmichService } from './immich.service';
|
||||
import { SynologyService } from './synology.service';
|
||||
import { UnifiedMemoriesService } from './unified-memories.service';
|
||||
import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { PhotoCaptureBackfillService } from './photo-capture-backfill.service';
|
||||
import { ThumbnailService } from './thumbnail.service';
|
||||
import { TrekPhotoCacheService } from './trek-photo-cache.service';
|
||||
import { TrekPhotoCacheJob } from './trek-photo-cache.job';
|
||||
@@ -54,6 +55,7 @@ import { StorageModule } from '../storage/storage.module';
|
||||
SynologyService,
|
||||
UnifiedMemoriesService,
|
||||
PhotoResolverService,
|
||||
PhotoCaptureBackfillService,
|
||||
ThumbnailService,
|
||||
TrekPhotoCacheService,
|
||||
TrekPhotoCacheJob,
|
||||
@@ -67,6 +69,6 @@ import { StorageModule } from '../storage/storage.module';
|
||||
inject: [ImmichPhotoProvider, SynologyPhotoProvider],
|
||||
},
|
||||
],
|
||||
exports: [MemoriesAccessService, PhotoResolverService, ImmichService, SynologyService, PhotoProviderRegistry],
|
||||
exports: [MemoriesAccessService, PhotoResolverService, PhotoCaptureBackfillService, ImmichService, SynologyService, PhotoProviderRegistry],
|
||||
})
|
||||
export class MemoriesModule {}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import exifr from 'exifr';
|
||||
import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
|
||||
/**
|
||||
* Ask the provider when and where a photo was taken, and record it (#1614).
|
||||
*
|
||||
* The picker already sees these values, but the add call carries only the asset
|
||||
* id — widening that contract would have meant trusting the client for something
|
||||
* the provider can be asked for directly, and would have left the MCP path, the
|
||||
* album sync and every already-imported photo without them.
|
||||
*
|
||||
* Runs detached and never throws: a provider that is slow, unreachable or simply
|
||||
* does not know must not fail the add the user is waiting on. A photo without
|
||||
* capture metadata is the normal case, not an error — it just will not appear on
|
||||
* the map.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhotoCaptureBackfillService {
|
||||
constructor(
|
||||
private readonly resolver: PhotoResolverService,
|
||||
private readonly photos: TrekPhotosRepository,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
/** Fire-and-forget for a batch that was just added. */
|
||||
schedule(trekPhotoIds: number[], userId: number): void {
|
||||
if (!trekPhotoIds.length) return;
|
||||
void this.run(trekPhotoIds, userId);
|
||||
}
|
||||
|
||||
/** Awaitable form, so tests do not have to chase a floating promise. */
|
||||
async run(trekPhotoIds: number[], userId: number): Promise<void> {
|
||||
for (const id of trekPhotoIds) {
|
||||
try {
|
||||
const photo = this.photos.resolve(id);
|
||||
// A row that already knows both has nothing to gain, and a provider call
|
||||
// per photo is the expensive part of an album import.
|
||||
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
|
||||
|
||||
// A local file has no provider to ask — the answer is in its own EXIF, and
|
||||
// getPhotoInfo would only hand back what the DB row already says.
|
||||
if (photo.provider === 'local') {
|
||||
const meta = await this.readLocalExif(photo.file_path);
|
||||
if (meta) this.photos.recordCaptureMetadata(id, meta);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = await this.resolver.getPhotoInfo(id, userId);
|
||||
if (!info.success) continue;
|
||||
|
||||
this.photos.recordCaptureMetadata(id, {
|
||||
takenAt: info.data.takenAt ?? null,
|
||||
lat: info.data.lat ?? null,
|
||||
lng: info.data.lng ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[Photos] capture backfill failed for ${id}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF of an uploaded file.
|
||||
*
|
||||
* Note for anyone chasing "my phone photo has no location": the client converts
|
||||
* HEIC before upload and that conversion drops GPS, so an iPhone photo arrives
|
||||
* here already stripped. Nothing to read is the expected outcome far more often
|
||||
* than not — hence no logging on the empty case.
|
||||
*/
|
||||
private async readLocalExif(
|
||||
filePath?: string | null,
|
||||
): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
|
||||
if (!filePath) return null;
|
||||
// photos.file_path is uploads-relative 'journey/<name>' by every writer;
|
||||
// anything else reads as a miss (same rule as photo-resolver). Central key
|
||||
// validation (storage-keys.ts) rejects a name that still carries a path,
|
||||
// so a stored path can never climb out of the journey category.
|
||||
if (!filePath.startsWith('journey/')) return null;
|
||||
const name = filePath.slice('journey/'.length);
|
||||
|
||||
type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
|
||||
let parsed: Exif | null;
|
||||
try {
|
||||
parsed = await this.storage.withLocalFile('journey', name, async abs =>
|
||||
(await exifr.parse(abs, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
|
||||
})) as Exif | null,
|
||||
);
|
||||
} catch {
|
||||
// A vanished object, an invalid key, not an image, a truncated upload,
|
||||
// a video — none of it is an error here.
|
||||
return null;
|
||||
}
|
||||
if (!parsed) return null;
|
||||
|
||||
const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
|
||||
const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
|
||||
const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
|
||||
const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
|
||||
return takenAt || lat != null ? { takenAt, lat, lng } : null;
|
||||
}
|
||||
}
|
||||
@@ -507,8 +507,10 @@ export class SynologyService {
|
||||
|
||||
while (true) {
|
||||
const params: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
// 'gps' costs nothing extra on the same call and is the only way a
|
||||
// photo picked from an album can ever reach the map (#1614).
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail', 'gps'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail', 'gps'] };
|
||||
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
const items = result.data.list || [];
|
||||
@@ -520,6 +522,8 @@ export class SynologyService {
|
||||
const assets = allItems.map(item => ({
|
||||
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
|
||||
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
|
||||
lat: typeof item.additional?.gps?.latitude === 'number' ? item.additional.gps.latitude : null,
|
||||
lng: typeof item.additional?.gps?.longitude === 'number' ? item.additional.gps.longitude : null,
|
||||
})).filter(a => a.id);
|
||||
|
||||
return success({ assets, total: assets.length, hasMore: false });
|
||||
|
||||
@@ -89,6 +89,36 @@ export class TrekPhotosRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where and when the picture was taken (#1614).
|
||||
*
|
||||
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
|
||||
* three sources disagree in how much they know. A provider search answers with
|
||||
* coordinates, the same provider's album listing does not, and a local file
|
||||
* only gives them up once its EXIF has been read. Whichever arrives first wins,
|
||||
* and a later, emptier answer must not blank what is already there.
|
||||
*
|
||||
* A photo with neither is the normal case, not a failure.
|
||||
*/
|
||||
recordCaptureMetadata(
|
||||
photoId: number,
|
||||
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
|
||||
): void {
|
||||
const { takenAt = null, lat = null, lng = null } = meta;
|
||||
if (takenAt == null && lat == null && lng == null) return;
|
||||
// Coordinates are stored as a pair or not at all — a lone latitude is not a
|
||||
// place, and half a pair would put the photo on the null island.
|
||||
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
|
||||
this.db.run(
|
||||
`UPDATE trek_photos
|
||||
SET taken_at = COALESCE(taken_at, ?),
|
||||
lat = COALESCE(lat, ?),
|
||||
lng = COALESCE(lng, ?)
|
||||
WHERE id = ?`,
|
||||
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the row once no trip and no journey references it. Local photos are
|
||||
* kept: their bytes are ours, and the file would outlive the row.
|
||||
|
||||
@@ -254,6 +254,12 @@ export const SEARCH_BIAS_RADIUS_METERS = 2000;
|
||||
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
|
||||
export const ENRICH_CONCURRENCY = 3;
|
||||
|
||||
// The free address backfill is one Nominatim request per place on the throttled
|
||||
// background lane (~1/s), so a normal list costs seconds. Past this it stops being
|
||||
// a backfill and starts being bulk geocoding, which Nominatim's usage policy asks
|
||||
// people not to do — so it stops rather than queueing for an hour.
|
||||
export const ADDRESS_BACKFILL_MAX_PLACES = 250;
|
||||
|
||||
|
||||
/**
|
||||
* Pick the search result that is the same place as the import: it must be a
|
||||
|
||||
@@ -30,6 +30,7 @@ import { StorageService } from '../storage/storage.service';
|
||||
import {
|
||||
COORD_DEDUP_TOLERANCE,
|
||||
ENRICH_CONCURRENCY,
|
||||
ADDRESS_BACKFILL_MAX_PLACES,
|
||||
escapeLikePattern,
|
||||
MAX_LIST_RESPONSE_BYTES,
|
||||
googleMapsFeatureIdFromItem,
|
||||
@@ -1007,8 +1008,8 @@ export class PlacesService {
|
||||
}
|
||||
});
|
||||
|
||||
if (opts?.enrich && opts.userId && created.length) {
|
||||
void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
|
||||
if (created.length) {
|
||||
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
|
||||
}
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
@@ -1150,8 +1151,8 @@ export class PlacesService {
|
||||
}
|
||||
});
|
||||
|
||||
if (opts?.enrich && opts.userId && created.length) {
|
||||
void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
|
||||
if (created.length) {
|
||||
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
|
||||
}
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
@@ -1250,6 +1251,63 @@ export class PlacesService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a just-imported list gets in the background, in one place so the
|
||||
* Google and Naver call sites cannot drift apart.
|
||||
*
|
||||
* The Google pass stays exactly as opt-in and key-gated as before. The address
|
||||
* backfill behind it needs neither — it is the same free Nominatim reverse
|
||||
* lookup a single pasted link has always had, which is what made a list import
|
||||
* come out thinner than the same place added one at a time (#1954).
|
||||
*/
|
||||
private async enrichImportedList(tripId: string, places: EnrichablePlace[], opts?: ListImportOptions): Promise<void> {
|
||||
if (opts?.enrich && opts.userId) {
|
||||
await this.enrichImportedPlaces(tripId, opts.userId, places, opts.lang);
|
||||
}
|
||||
await this.backfillMissingAddresses(tripId, places, opts?.lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the address of imported places that have none, from Nominatim.
|
||||
*
|
||||
* Runs on the background lane so it never queues in front of a user's own
|
||||
* search, writes through COALESCE so it can only fill an empty column — the
|
||||
* Google pass above may already have written a better one — and pushes each
|
||||
* row over the websocket so the sidebar fills in without a reload. Never
|
||||
* throws: one bad lookup must not take down a detached task.
|
||||
*/
|
||||
async backfillMissingAddresses(tripId: string, places: EnrichablePlace[], lang?: string): Promise<void> {
|
||||
try {
|
||||
const pending = places.filter(p => !p.address && p.lat != null && p.lng != null);
|
||||
if (!pending.length) return;
|
||||
if (pending.length > ADDRESS_BACKFILL_MAX_PLACES) {
|
||||
console.warn(`[Places] address backfill skipped for trip ${tripId}: ${pending.length} places exceeds the ${ADDRESS_BACKFILL_MAX_PLACES} cap`);
|
||||
return;
|
||||
}
|
||||
// Serial on purpose: the background lane throttles to roughly one request a
|
||||
// second anyway, so concurrency would only build a queue.
|
||||
for (const place of pending) {
|
||||
try {
|
||||
const { address } = await this.maps.reverseGeocode(String(place.lat), String(place.lng), lang, {
|
||||
lane: 'background',
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
if (!address) continue;
|
||||
this.dbs.run(
|
||||
'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
address, place.id, tripId,
|
||||
);
|
||||
const updated = this.dbs.getPlaceWithTags(place.id);
|
||||
if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
} catch (err) {
|
||||
console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Places] address backfill pass failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Search place image (Unsplash)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -90,6 +90,7 @@ function parseId(value: string | string[]): number | null {
|
||||
const legsSchema = transportLegsInputSchema.optional().describe(
|
||||
'Per-segment detail of a stopover flight or train: one entry per segment, in route order, exactly ONE FEWER than endpoints[]. '
|
||||
+ 'Each leg carries its own departure and arrival day + local time (dep_day_id/dep_time, arr_day_id/arr_time) plus airline/flight_number (flights) or train_number/platform (trains). '
|
||||
+ 'A leg may also carry its own confirmation_number when that segment was issued a separate booking reference; leave it out and the booking-level confirmation_number covers the segment. '
|
||||
+ "Required for a booking with stopovers: a stop's endpoint stores only the onward departure, so without legs that one time is shown as both the arrival and the departure. "
|
||||
+ 'Omit it for a direct booking. Blank endpoint times/dates, day_id/end_day_id and reservation_time are derived from the legs; a value that contradicts them is rejected.'
|
||||
);
|
||||
@@ -207,7 +208,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } {
|
||||
from: leg.from ?? depEp.code ?? depEp.name,
|
||||
to: leg.to ?? arrEp.code ?? arrEp.name,
|
||||
};
|
||||
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat'] as const) {
|
||||
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat', 'confirmation_number'] as const) {
|
||||
const value = leg[key];
|
||||
if (value) entry[key] = value;
|
||||
}
|
||||
@@ -570,7 +571,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_transport',
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||
@@ -678,7 +679,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_transport',
|
||||
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. Use status "confirmed" to confirm.',
|
||||
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. A per-segment booking reference lives on the leg (legs[].confirmation_number); the top-level one stays the booking reference. Use status "confirmed" to confirm.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
|
||||
/**
|
||||
* The third-party keys that belong to the instance rather than to a person.
|
||||
*
|
||||
* Google Places and Unsplash bill the install, not the account that pasted the
|
||||
* key in, so the value is instance configuration — the same argument #1772 made
|
||||
* for the LLM endpoint (see llm-parse/llm-config.ts: instance-wide, admin-set,
|
||||
* wins; per-user as the fallback). Both keys used to be resolved out of the
|
||||
* `users` columns alone, which meant "the key of the admin with the lowest id"
|
||||
* for everyone else and produced #1939: the admin searched with the key they
|
||||
* had just saved, every other member searched with a stranger's and got a 403
|
||||
* from Google.
|
||||
*
|
||||
* Stored as an encrypted `app_settings` row under the name below, reusing
|
||||
* apiKeyCrypto, so the format matches what the users columns already hold and
|
||||
* a legacy plaintext value still reads back.
|
||||
*/
|
||||
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
|
||||
|
||||
/** Instance names whose per-user column is still honoured as a last resort. */
|
||||
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
|
||||
|
||||
/**
|
||||
* Where a resolved key came from. Logged beside a provider error so "works for
|
||||
* the admin, 403 for everyone else" is one line in the log rather than a
|
||||
* guessing game; the key itself is never logged.
|
||||
*/
|
||||
export type ApiKeySource = 'operator-env' | 'instance' | 'user-row';
|
||||
|
||||
// Full statements rather than an interpolated column: the name doubles as the
|
||||
// users column AND the app_settings key, and identifiers only ever come from a
|
||||
// literal allow-list.
|
||||
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
|
||||
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
|
||||
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
|
||||
};
|
||||
|
||||
/** The instance-wide value in cleartext, or null when unset/cleared. */
|
||||
export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
|
||||
const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
|
||||
if (!row?.value) return null;
|
||||
return decrypt_api_key(row.value) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the instance-wide value, encrypted. A blank value stores '' rather than
|
||||
* deleting the row: an admin who clears the field means "this instance has no
|
||||
* key", and a missing row would let the resolver fall through to whatever old
|
||||
* value still sits in their own users column.
|
||||
*/
|
||||
export function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): void {
|
||||
db.run(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
name, maybe_encrypt_api_key(value) ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a key for one request: operator env → instance-wide → the caller's
|
||||
* own row.
|
||||
*
|
||||
* The caller's own row is last, not second, and no other user's row is ever
|
||||
* read — falling back to a stranger's key is what server/CLAUDE.md forbids and
|
||||
* what #1939 reported. It stays as a fallback because `PUT /me/api-keys` has
|
||||
* always accepted a personal key and dropping it would break the installs where
|
||||
* each member pays for their own.
|
||||
*
|
||||
* `userId` 0 means "nobody is asking" (the unauthenticated app-config read):
|
||||
* there is no personal key to find, so the chain ends at the instance.
|
||||
*/
|
||||
export function resolveApiKey(
|
||||
db: DatabaseService,
|
||||
name: InstanceApiKeyName,
|
||||
userId: number,
|
||||
operatorKey: string | undefined,
|
||||
): { key: string | null; source: ApiKeySource | null } {
|
||||
if (operatorKey) return { key: operatorKey, source: 'operator-env' };
|
||||
|
||||
const instance = readInstanceApiKey(db, name);
|
||||
if (instance) return { key: instance, source: 'instance' };
|
||||
if (!userId) return { key: null, source: null };
|
||||
|
||||
const row = db.get<Record<string, string | null>>(USER_ROW_SQL[name], userId);
|
||||
const own = decrypt_api_key(row?.[name]) || null;
|
||||
return own ? { key: own, source: 'user-row' } : { key: null, source: null };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Readable } from 'node:stream';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { resolveApiKey } from '../settings/instance-api-keys';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
@@ -42,7 +42,7 @@ const COVER_EXT_BY_TYPE: Record<string, string> = {
|
||||
* Unsplash search and cover download.
|
||||
*
|
||||
* Its own module rather than a method on trips or places: both call it, and the
|
||||
* key resolution reads the env AND the users table, so it needs the injected
|
||||
* key resolution reads the env AND the database, so it needs the injected
|
||||
* connection either way.
|
||||
*/
|
||||
@Injectable()
|
||||
@@ -56,18 +56,14 @@ export class UnsplashService {
|
||||
/**
|
||||
* Resolve an Unsplash access key for a request, in precedence order:
|
||||
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
|
||||
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
|
||||
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
|
||||
* in which case the search falls back to the unauthenticated endpoint.
|
||||
* SMTP/OIDC env-over-DB convention) → the instance-wide value the admin panel
|
||||
* saves → the caller's own stored key. Mirrors `resolveMapsKey`, including what
|
||||
* it dropped: "any admin's stored key" read a stranger's credential and made
|
||||
* the answer depend on who asked (#1939). Returns null when none is set, in
|
||||
* which case the search falls back to the unauthenticated endpoint.
|
||||
*/
|
||||
getUnsplashKey(userId: number): string | null {
|
||||
const env_key = this.env.env().integrations.unsplashAccessKey;
|
||||
if (env_key) return env_key;
|
||||
const user = this.db.get<{ unsplash_api_key: string | null }>('SELECT unsplash_api_key FROM users WHERE id = ?', userId);
|
||||
const user_key = decrypt_api_key(user?.unsplash_api_key);
|
||||
if (user_key) return user_key;
|
||||
const admin = this.db.get<{ unsplash_api_key: string }>("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1");
|
||||
return decrypt_api_key(admin?.unsplash_api_key) || null;
|
||||
return resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey).key;
|
||||
}
|
||||
|
||||
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
|
||||
|
||||
@@ -27,12 +27,14 @@ export class WeatherController {
|
||||
@Query('lng') lng?: string,
|
||||
@Query('date') date?: string,
|
||||
@Query('lang') lang?: string,
|
||||
/** HH:MM — lets a past date answer for that hour rather than the day (#1614). */
|
||||
@Query('time') time?: string,
|
||||
): Promise<WeatherResult> {
|
||||
if (!lat || !lng) {
|
||||
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.weather.get(lat, lng, date, lang ?? 'de');
|
||||
return await this.weather.get(lat, lng, date, lang ?? 'de', time);
|
||||
} catch (err: unknown) {
|
||||
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
|
||||
// Open-Meteo sits on the request path as a third party: without a deadline a hung
|
||||
// connection holds the caller until the socket gives up on its own. Every other
|
||||
// outbound client in the codebase carries one.
|
||||
const WEATHER_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Which slot of Open-Meteo's hourly series a HH:MM belongs to.
|
||||
*
|
||||
* The archive answers with one entry per hour of the requested day in the local
|
||||
* timezone, so the hour is the index. Anything unparseable means "no time given",
|
||||
* which falls back to the daily figures rather than guessing midnight.
|
||||
*/
|
||||
function hourIndexFromTime(time?: string): number | null {
|
||||
if (!time) return null;
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(time.trim());
|
||||
if (!m) return null;
|
||||
const hour = Number(m[1]);
|
||||
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
|
||||
}
|
||||
|
||||
// ── Interfaces ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface WeatherResult {
|
||||
@@ -177,8 +197,9 @@ async function _getWeatherImpl(
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
lang: string,
|
||||
time?: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = cacheKey(lat, lng, date);
|
||||
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
|
||||
|
||||
if (date) {
|
||||
const cached = getCached(ck);
|
||||
@@ -191,7 +212,7 @@ async function _getWeatherImpl(
|
||||
// Forecast range (-1 .. +16 days)
|
||||
if (diffDays >= -1 && diffDays <= 16) {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
@@ -222,14 +243,36 @@ async function _getWeatherImpl(
|
||||
// Past date: use archive API for the actual date
|
||||
if (diffDays < -1) {
|
||||
const dateStr = targetDate.toISOString().slice(0, 10);
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
// With a time in hand, ask for the hourly series too: a journal entry made at
|
||||
// 14:30 wants the weather of that hour, not the day's high and low averaged
|
||||
// together, which is what a rainy morning and a bright afternoon collapse to.
|
||||
const hourParam = hourIndexFromTime(time) != null
|
||||
? '&hourly=temperature_2m,weathercode'
|
||||
: '';
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum${hourParam}&timezone=auto`;
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error');
|
||||
}
|
||||
|
||||
const hourIdx = hourIndexFromTime(time);
|
||||
const hourly = (data as { hourly?: { temperature_2m?: (number | null)[]; weathercode?: (number | null)[] } }).hourly;
|
||||
if (hourIdx != null && hourly?.temperature_2m?.[hourIdx] != null) {
|
||||
const temp = hourly.temperature_2m[hourIdx]!;
|
||||
const code = hourly.weathercode?.[hourIdx] ?? undefined;
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
const result: WeatherResult = {
|
||||
temp: Math.round(temp),
|
||||
main: WMO_MAP[code!] || estimateCondition(temp, 0),
|
||||
description: descriptions[code!] || '',
|
||||
type: 'forecast',
|
||||
};
|
||||
setCache(ck, result, TTL_CLIMATE_MS);
|
||||
return result;
|
||||
}
|
||||
|
||||
const daily = data.daily;
|
||||
if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
|
||||
const code = daily.weathercode?.[0];
|
||||
@@ -264,7 +307,7 @@ async function _getWeatherImpl(
|
||||
const endStr = endDate.toISOString().slice(0, 10);
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
@@ -317,7 +360,7 @@ async function _getWeatherImpl(
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
@@ -343,15 +386,17 @@ export async function getWeather(
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
lang: string,
|
||||
/** HH:MM of the moment being asked about. Only the archive path can use it. */
|
||||
time?: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = cacheKey(lat, lng, date);
|
||||
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getWeatherImpl(lat, lng, date, lang);
|
||||
const promise = _getWeatherImpl(lat, lng, date, lang, time);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
@@ -388,7 +433,7 @@ async function _getDetailedWeatherImpl(
|
||||
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
|
||||
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
|
||||
+ `&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
@@ -451,7 +496,7 @@ async function _getDetailedWeatherImpl(
|
||||
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
|
||||
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
|
||||
@@ -25,8 +25,8 @@ export class WeatherService implements OnModuleInit, OnModuleDestroy {
|
||||
stopCacheCleanup();
|
||||
}
|
||||
|
||||
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
|
||||
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||
get(lat: string, lng: string, date: string | undefined, lang: string, time?: string): Promise<WeatherResult> {
|
||||
return getWeather(lat, lng, date, lang, time) as Promise<WeatherResult>;
|
||||
}
|
||||
|
||||
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
|
||||
|
||||
@@ -398,6 +398,11 @@ export interface TrekPhoto {
|
||||
media_type?: string | null;
|
||||
/** Optional video duration in milliseconds. */
|
||||
duration_ms?: number | null;
|
||||
/** When the picture was taken, as the provider or its EXIF reported it (#1614). */
|
||||
taken_at?: string | null;
|
||||
/** Capture coordinates. Stored as a pair or not at all. */
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user