mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
chore: move share backend to proper DI
This commit is contained in:
@@ -1,282 +0,0 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { serveFilePath } from './placePhotoCache';
|
||||
import { getUserSettings } from './settingsService';
|
||||
|
||||
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
|
||||
|
||||
/**
|
||||
* Place photo proxy URLs (`/api/maps/place-photo/<id>/bytes`) are served by the
|
||||
* JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip
|
||||
* viewer. Rewrite them to the public, token-scoped equivalent
|
||||
* (`/api/shared/<token>/place-photo/<id>/bytes`) so thumbnails load in a shared
|
||||
* link. A simple prefix swap keeps the already-encoded placeId segment intact, so
|
||||
* the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through.
|
||||
*/
|
||||
function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null {
|
||||
if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) {
|
||||
return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`;
|
||||
}
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
interface SharePermissions {
|
||||
share_map?: boolean;
|
||||
share_bookings?: boolean;
|
||||
share_packing?: boolean;
|
||||
share_budget?: boolean;
|
||||
share_collab?: boolean;
|
||||
}
|
||||
|
||||
interface ShareTokenInfo {
|
||||
token: string;
|
||||
created_at: string;
|
||||
share_map: boolean;
|
||||
share_bookings: boolean;
|
||||
share_packing: boolean;
|
||||
share_budget: boolean;
|
||||
share_collab: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new share link or updates the permissions on an existing one.
|
||||
* Returns an object with the token string and whether it was newly created.
|
||||
*/
|
||||
export function createOrUpdateShareLink(
|
||||
tripId: string,
|
||||
createdBy: number,
|
||||
permissions: SharePermissions
|
||||
): { token: string; created: boolean } {
|
||||
const {
|
||||
share_map = true,
|
||||
share_bookings = true,
|
||||
share_packing = false,
|
||||
share_budget = false,
|
||||
share_collab = false,
|
||||
} = permissions;
|
||||
|
||||
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
|
||||
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
// New share links default to a 90-day TTL. Existing tokens that were
|
||||
// created before the expires_at migration keep NULL here and remain
|
||||
// valid indefinitely until the owner rotates them; that preserves
|
||||
// behaviour for anyone who's already sharing a link.
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns share token info for a trip, or null if no share link exists.
|
||||
*/
|
||||
export function getShareLink(tripId: string): ShareTokenInfo | null {
|
||||
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
if (!row) return null;
|
||||
return {
|
||||
token: row.token,
|
||||
created_at: row.created_at,
|
||||
share_map: !!row.share_map,
|
||||
share_bookings: !!row.share_bookings,
|
||||
share_packing: !!row.share_packing,
|
||||
share_budget: !!row.share_budget,
|
||||
share_collab: !!row.share_collab,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the share token for a trip.
|
||||
*/
|
||||
export function deleteShareLink(tripId: string): void {
|
||||
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full public trip data for a share token, filtered by the token's
|
||||
* permission flags. Returns null if the token is invalid or the trip is gone.
|
||||
*/
|
||||
export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
const shareRow = db.prepare(
|
||||
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as any;
|
||||
if (!shareRow) return null;
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
// Trip
|
||||
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
|
||||
if (!trip) return null;
|
||||
|
||||
// Days with assignments
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
|
||||
const dayIds = days.map(d => d.id);
|
||||
|
||||
let assignments: Record<number, any[]> = {};
|
||||
let dayNotes: Record<number, any[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const ph = dayIds.map(() => '?').join(',');
|
||||
const allAssignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const byDay: Record<number, any[]> = {};
|
||||
for (const a of allAssignments as any[]) {
|
||||
if (!byDay[a.day_id]) byDay[a.day_id] = [];
|
||||
byDay[a.day_id].push({
|
||||
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
|
||||
place: {
|
||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||
image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
|
||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||
tags: tagsByPlace[a.place_id] || [],
|
||||
}
|
||||
});
|
||||
}
|
||||
assignments = byDay;
|
||||
|
||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||
notesByDay[n.day_id].push(n);
|
||||
}
|
||||
dayNotes = notesByDay;
|
||||
}
|
||||
|
||||
// Places
|
||||
const places = (db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`).all(tripId) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
|
||||
|
||||
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||
|
||||
const dayPositions = db.prepare(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
}
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
|
||||
FROM day_accommodations a JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Packing — a public viewer is neither owner nor recipient, so only Common items
|
||||
// may surface; never a co-member's private/personal packing items (#858).
|
||||
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC').all(tripId);
|
||||
|
||||
// Budget
|
||||
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
|
||||
|
||||
// Categories
|
||||
const categories = db.prepare('SELECT * FROM categories').all();
|
||||
|
||||
const permissions = {
|
||||
share_map: !!shareRow.share_map,
|
||||
share_bookings: !!shareRow.share_bookings,
|
||||
share_packing: !!shareRow.share_packing,
|
||||
share_budget: !!shareRow.share_budget,
|
||||
share_collab: !!shareRow.share_collab,
|
||||
};
|
||||
|
||||
// Collab messages (only if owner chose to share)
|
||||
const collabMessages = permissions.share_collab
|
||||
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at').all(tripId)
|
||||
: [];
|
||||
|
||||
// Display currency the share owner sees in their Costs view. A public viewer has
|
||||
// no logged-in user, so the owner's per-user `default_currency` (with the admin
|
||||
// instance default already merged in by getUserSettings) is embedded in the
|
||||
// payload and used by the client to convert every expense — otherwise guests
|
||||
// fall back to the trip's base currency and see the wrong totals (#1361).
|
||||
// getUserSettings merges admin defaults under the user's own settings, so this
|
||||
// honours per-user → admin-default; we then fall back to trip currency → EUR.
|
||||
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
|
||||
if (shareRow.created_by != null) {
|
||||
const ownerDefault = getUserSettings(shareRow.created_by)['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Honour every share flag server-side — the client gates these too, but it must
|
||||
// not rely on that (mirrors journeyShareService). share_map covers the whole
|
||||
// itinerary: days, their assignments/notes, and the place list with coordinates,
|
||||
// addresses and notes. Withhold it when the owner disabled the map.
|
||||
return {
|
||||
trip, baseCurrency, categories, permissions,
|
||||
days: permissions.share_map ? days : [],
|
||||
assignments: permissions.share_map ? assignments : {},
|
||||
dayNotes: permissions.share_map ? dayNotes : {},
|
||||
places: permissions.share_map ? places : [],
|
||||
reservations: permissions.share_bookings ? reservations : [],
|
||||
accommodations: permissions.share_bookings ? accommodations : [],
|
||||
packing: permissions.share_packing ? packing : [],
|
||||
budget: permissions.share_budget ? budget : [],
|
||||
collab: collabMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for a cached place photo requested through a public
|
||||
* share link. Validates that the token is valid + unexpired and that the place
|
||||
* actually belongs to that token's trip (matched via the stored proxy URL, which
|
||||
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
|
||||
* depending on google_place_id). Returns null — never throws — so the caller
|
||||
* answers a plain 404, mirroring the authenticated bytes endpoint.
|
||||
*/
|
||||
export function getSharedPlacePhotoPath(token: string, placeId: string): string | null {
|
||||
const shareRow = db.prepare(
|
||||
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as { trip_id: string; share_map: number } | undefined;
|
||||
if (!shareRow) return null;
|
||||
// Place photos belong to the map/itinerary section — withhold them when the
|
||||
// owner disabled the map, matching getSharedTripData which no longer returns
|
||||
// the places (and thus their ids) in that case.
|
||||
if (!shareRow.share_map) return null;
|
||||
|
||||
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
|
||||
const place = db.prepare(
|
||||
'SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?'
|
||||
).get(shareRow.trip_id, expectedUrl);
|
||||
if (!place) return null;
|
||||
|
||||
return serveFilePath(placeId);
|
||||
}
|
||||
Reference in New Issue
Block a user