Spaces:
Sleeping
Sleeping
File size: 10,721 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
import { serveFilePath } from './placePhotoCache';
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
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? 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)
: [];
return {
trip, days, assignments, dayNotes, places, categories, permissions,
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 FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(token) as { trip_id: string } | undefined;
if (!shareRow) 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);
}
|