refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories

This commit is contained in:
jubnl
2026-09-24 01:24:21 +02:00
parent 36e178c085
commit 425d889983
42 changed files with 1022 additions and 225 deletions
@@ -248,6 +248,15 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).execute();
}
/**
* `share.service.ts:391` SH14 (`getSharedTripData`'s share_budget read) —
* `SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC`
* (unlike BG72's `listAllForTrip` above, which has no `ORDER BY`).
*/
async listPublicForShare(trip_id: number | string): Promise<BudgetItemRow[]> {
return await this.kysely<BudgetKyselyDB>().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).orderBy('category', 'asc').execute();
}
/**
* BG71 (`getPerPersonSummary`) — the correlated-subquery-inside-`SUM`
* aggregate (T6, per the plan's own ruling): `SELECT bm.user_id,
@@ -21,6 +21,18 @@ export class CategoriesRepository extends TrekRepository<Categories> {
return categories.map((category) => toRow(category) as CategoryRow);
}
/**
* `share.service.ts:396` SH15 (`getSharedTripData`) — `SELECT * FROM
* categories`, no `ORDER BY` (unlike `list()` above, which adds `ORDER BY
* name ASC` for the authenticated `/api/categories` list — the legacy
* public-share statement never sorted). Unscoped by trip on purpose: "a
* shared global pool", the same statement the authed list reads from.
*/
async listAllUnordered(): Promise<CategoryRow[]> {
const categories = await this.find({});
return categories.map((category) => toRow(category) as CategoryRow);
}
/**
* PL33 (`PlacesService.importKmlPlaces`) — `SELECT id, name FROM
* categories`, the id/name-only projection the KML importer folds into
@@ -24,6 +24,12 @@ export interface CollabMessageJoinRow extends CollabMessageRow {
reply_username: string | null;
}
/** {@link CollabMessagesRepository.listPublicForShare}'s projection (SH16) — `m.*, u.username, u.avatar` only. */
export interface SharePublicCollabMessageRow extends CollabMessageRow {
username: string;
avatar: string | null;
}
interface CollabMessagesKyselyDB {
collab_messages: CollabMessageRow;
users: { id: number; username: string; avatar: string | null };
@@ -117,6 +123,27 @@ export class CollabMessagesRepository extends TrekRepository<CollabMessages> {
return await this.joinedQuery().where('m.id', '=', id).executeTakeFirst();
}
/**
* `share.service.ts:400` SH16 (`getSharedTripData`'s share_collab read) —
* `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`. A narrower projection than {@link joinedQuery} above (no
* `reply_text`/`reply_username` — a public share viewer never resolves a
* reply thread through this read) and a different order/shape than
* `listForTrip` (ascending, no cursor, no `LIMIT`).
*/
async listPublicForShare(trip_id: number): Promise<SharePublicCollabMessageRow[]> {
return await this.kysely<CollabMessagesKyselyDB>()
.selectFrom('collab_messages as m')
.innerJoin('users as u', 'u.id', 'm.user_id')
.selectAll('m')
.select(['u.username', 'u.avatar'])
.where('m.trip_id', '=', trip_id)
.where('m.deleted', '=', 0)
.orderBy('m.created_at', 'asc')
.execute();
}
/**
* CB2/CB51 (`reactMessage`'s existence guard, `deleteMessage`'s
* trip-scoping + owner-check read) — `SELECT * FROM collab_messages WHERE
@@ -418,6 +418,39 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
.executeTakeFirst();
return Number(result.numInsertedOrUpdatedRows ?? 0);
}
// ---------------------------------------------------------------------
// Plan 3h Task 6 (survivors — `places.service.ts` SV-PI2, `place-photo-
// cache.service.ts` SV-PP6, `place-image.ts` SV-PLACEIMG-2) — additive.
// ---------------------------------------------------------------------
/**
* SV-PI2 (`places.service.ts::reclaimPlaceImage`) / SV-PLACEIMG-2
* (`CollectionsService`'s own private `reclaimPlaceImage`, replacing
* `place-image.ts`'s free function) — `SELECT 1 FROM collection_places
* WHERE image_url = ? LIMIT 1`. Trip-agnostic, same shape as
* `PlacesRepository.existsByImageUrl` for its own table.
*/
async existsByImageUrl(imageUrl: string): Promise<boolean> {
const row = await this.db_().selectFrom('collection_places').select('id').where('image_url', '=', imageUrl).limit(1).executeTakeFirst();
return !!row;
}
/**
* SV-PP6 (`place-photo-cache.service.ts::isReferenced`) — `SELECT 1 FROM
* collection_places WHERE google_place_id = ? OR image_url = ? LIMIT 1`,
* the same shape `PlacesRepository.existsByGoogleIdOrImageUrl` uses for
* its own table.
*/
async existsByGoogleIdOrImageUrl(googlePlaceId: string, imageUrl: string): Promise<boolean> {
const row = await this.db_()
.selectFrom('collection_places')
.select('id')
.where((eb) => eb.or([eb('google_place_id', '=', googlePlaceId), eb('image_url', '=', imageUrl)]))
.limit(1)
.executeTakeFirst();
return !!row;
}
}
/** {@link CollectionPlacesRepository.findForCopy}'s narrow projection (CL62). */
@@ -63,6 +63,16 @@ export interface AssignmentWithPlaceRow extends DayAssignmentRow {
category_icon: string | null;
}
/**
* {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) —
* {@link AssignmentWithPlaceRow} minus the six columns a public share link
* must not leak.
*/
export type SharePublicAssignmentRow = Omit<
AssignmentWithPlaceRow,
'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent'
>;
/**
* `day_assignments`'s shape as DY24's `em.getKysely()` call needs it (an
* explicit `TDB` type argument, per `WebauthnChallengesRepository
@@ -308,6 +318,53 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
.execute<AssignmentWithPlaceRow[]>('all', false);
}
/**
* `share.service.ts:288` SH9 (`getSharedTripData`'s share_map assignment
* read) — the SAME projection {@link assignmentWithPlaceSelect} builds,
* minus six internal-id/routing columns a public share link must never
* leak (`google_place_id`, `google_ftid`, `osm_id`, `amap_poi_id`,
* `stop_type`, `fill_percent`) — never widen this list to match the
* private `listWithPlaceAndCategory` projection above.
*/
private publicAssignmentSelect(platform: Platform) {
return [
'da.*',
'p.id as place_id',
'p.name as place_name',
'p.description as place_description',
'p.lat',
'p.lng',
'p.address',
columnRef(platform, 'p.category_id'),
'p.price',
'p.currency as place_currency',
coalesce(platform, 'da.assignment_time', 'p.place_time').as('place_time'),
coalesce(platform, 'da.assignment_end_time', 'p.end_time').as('end_time'),
'p.duration_minutes',
'p.notes as place_notes',
'p.image_url',
'p.transport_mode',
'p.website',
'p.phone',
'c.name as category_name',
'c.color as category_color',
'c.icon as category_icon',
] as const;
}
/** SH9 — same WHERE/ORDER BY shape as {@link listWithPlaceAndCategory}, narrower projection. */
async listPublicForShare(day_ids: number[]): Promise<SharePublicAssignmentRow[]> {
if (day_ids.length === 0) return [];
const platform = this.getEntityManager().getPlatform();
return await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.category', 'c')
.select(this.publicAssignmentSelect(platform))
.where({ 'da.day': { $in: day_ids } })
.orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' })
.execute<SharePublicAssignmentRow[]>('all', false);
}
/**
* DY24 (`days.service.ts::resyncAccommodationDays`'s `moveStayStop`,
* inventory §18.2) — a correlated scalar subquery in an UPDATE SET clause
@@ -117,6 +117,25 @@ export class PackingItemsRepository extends TrekRepository<PackingItems> {
return await this.db().selectFrom('packing_items').selectAll().where('trip_id', '=', trip_id as number).orderBy('sort_order', 'asc').orderBy('created_at', 'asc').execute();
}
/**
* `share.service.ts:386` SH13 (`getSharedTripData`'s share_packing read)
* — **SECURITY-CRITICAL (#858)**: `SELECT * FROM packing_items WHERE
* trip_id = ? AND is_private = 0 ORDER BY sort_order ASC` — a public
* viewer is neither owner nor recipient, so only Common items may
* surface. `ORDER BY sort_order ASC` only (unlike PK4's `listForTrip`,
* which also orders by `created_at` — the legacy public statement never
* added that second key).
*/
async listPublicForShare(trip_id: number | string): Promise<PackingItemRow[]> {
return await this.db()
.selectFrom('packing_items')
.selectAll()
.where('trip_id', '=', trip_id as number)
.where('is_private', '=', 0)
.orderBy('sort_order', 'asc')
.execute();
}
/**
* PK5 (`listItems`'s viewer-filtered branch, T5) — `SELECT * FROM
* packing_items WHERE trip_id = ? AND (${VISIBLE_TO_ACTOR}) ORDER BY
@@ -53,6 +53,28 @@ export interface PlaceWithCategoryRow extends PlaceRow {
category_icon: string | null;
}
/**
* {@link PlacesRepository.listPublicForShare}'s projection (SH12) — the
* `PUBLIC_PLACE_COLUMNS` allow-list plus the category join, picked out of
* {@link PlaceWithCategoryRow} rather than re-derived (never independently
* widen this list — see that method's docstring).
*/
export type SharePublicPlaceRow = Pick<
PlaceWithCategoryRow,
| 'id' | 'trip_id' | 'name' | 'description' | 'lat' | 'lng' | 'address' | 'category_id' | 'price' | 'currency'
| 'place_time' | 'end_time' | 'duration_minutes' | 'notes' | 'image_url' | 'website' | 'phone' | 'transport_mode'
| 'created_at' | 'updated_at' | 'category_name' | 'category_color' | 'category_icon'
>;
interface SharePublicPlaceKyselyDB {
places: Pick<PlaceRow,
| 'id' | 'trip_id' | 'name' | 'description' | 'lat' | 'lng' | 'address' | 'category_id' | 'price' | 'currency'
| 'place_time' | 'end_time' | 'duration_minutes' | 'notes' | 'image_url' | 'website' | 'phone' | 'transport_mode'
| 'created_at' | 'updated_at'
>;
categories: { id: number; name: string; color: string | null; icon: string | null };
}
interface PlaceRatingRow {
user_id: number;
username: string;
@@ -756,6 +778,58 @@ export class PlacesRepository extends TrekRepository<Places> {
.execute<PlaceRow[]>('all', false);
}
// ---------------------------------------------------------------------------
// Plan 3h Task 6 (`share.service.ts`) — additive.
// ---------------------------------------------------------------------------
/**
* `share.service.ts:340` SH12 (`getSharedTripData`'s share_map place-pool
* read) — **SECURITY-CRITICAL (#2320)**: a named 20-column allow-list,
* never `p.*`. Withheld on purpose (matching `PUBLIC_PLACE_COLUMNS`'s own
* doc comment in `share.service.ts`): the owner's booking-status columns
* (`reservation_status`/`reservation_notes`/`reservation_datetime`), every
* routing/dedup id (`google_place_id`, `google_ftid`, `osm_id`,
* `amap_poi_id`), `route_geometry`/`route_color`/`stop_type`/
* `fill_percent`, and `source`. Kysely, not `qb()`+`columnRef`: this read
* joins `p.category` under alias `c`, and both `trip_id`/`category_id` are
* `persist(false)` mirror columns — the exact trap
* `DayAssignmentsRepository.assignmentWithPlaceSelect`'s docstring
* documents. `ORDER BY p.created_at DESC`, matching the legacy statement.
*/
async listPublicForShare(trip_id: number | string): Promise<SharePublicPlaceRow[]> {
return await this.kysely<SharePublicPlaceKyselyDB>()
.selectFrom('places as p')
.leftJoin('categories as c', 'c.id', 'p.category_id')
.select([
'p.id', 'p.trip_id', 'p.name', 'p.description', 'p.lat', 'p.lng', 'p.address', 'p.category_id',
'p.price', 'p.currency', 'p.place_time', 'p.end_time', 'p.duration_minutes', 'p.notes',
'p.image_url', 'p.website', 'p.phone', 'p.transport_mode', 'p.created_at', 'p.updated_at',
'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon',
])
.where('p.trip_id', '=', trip_id as number)
.orderBy('p.created_at', 'desc')
.execute();
}
/**
* `share.service.ts:461` SH18 (`getSharedPlacePhotoKey`) —
* **SECURITY-CRITICAL**: the anonymous photo-serving ownership gate.
* `SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?` — both must
* match, proving the requested proxy URL belongs to a place on the SAME
* trip the share token covers, never just any place anywhere with that
* `image_url`.
*/
async existsByTripAndImageUrl(trip_id: number | string, imageUrl: string): Promise<boolean> {
const row = await this.kysely<{ places: { id: number; trip_id: number; image_url: string | null } }>()
.selectFrom('places')
.select('id')
.where('trip_id', '=', trip_id as number)
.where('image_url', '=', imageUrl)
.limit(1)
.executeTakeFirst();
return !!row;
}
// ---------------------------------------------------------------------------
// Plan 3c Task 8 (`TripsService.copy`) — additive.
// ---------------------------------------------------------------------------
@@ -1,4 +1,160 @@
import type { ShareTokens } from '../entities/ShareTokens.entity';
import { currentTimestampKysely } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class ShareTokensRepository extends TrekRepository<ShareTokens> {}
/**
* `share_tokens` — public trip-share links (Plan 3h Task 6). `trip_id` and
* `created_by` are `persist(false)` shadow mirrors of the `trip`/`createdByRef`
* relations (`ShareTokens.entity.ts`), so every READ here goes through
* `this.db_()` (Kysely), never a bare `qb().select(['trip_id', ...])` — the
* `CollectionPlacesRepository`/`DayAssignmentsRepository.assignmentWithPlaceSelect`
* class-docstring trap applies here too. WRITES go through the MikroORM
* entity API (`insert`/`nativeUpdate`/`nativeDelete`), using the entity's own
* relation property names (`trip`/`createdByRef`) for the two FK columns —
* the `DocumentConnectionsRepository.insertConnection` precedent.
*/
export interface ShareTokenRow {
id: number;
trip_id: number;
token: string;
created_by: number;
share_map: number | null;
share_bookings: number | null;
share_packing: number | null;
share_budget: number | null;
share_collab: number | null;
created_at: string | null;
expires_at: string | null;
}
interface ShareTokensKyselyDB {
share_tokens: ShareTokenRow;
}
export class ShareTokensRepository extends TrekRepository<ShareTokens> {
private db_() {
return this.kysely<ShareTokensKyselyDB>();
}
/**
* SH1 (`createOrUpdate`, TX) — `SELECT token FROM share_tokens WHERE
* trip_id = ?`.
*/
async findTokenByTrip(trip_id: number | string): Promise<{ token: string } | undefined> {
return await this.db_().selectFrom('share_tokens').select('token').where('trip_id', '=', trip_id as number).executeTakeFirst();
}
/**
* SH2 (`createOrUpdate`, TX, existing row) — `UPDATE share_tokens SET
* share_map=?, share_bookings=?, share_packing=?, share_budget=?,
* share_collab=?, expires_at=? WHERE trip_id=?`.
*/
async updateFlagsByTrip(trip_id: number | string, data: {
share_map: number;
share_bookings: number;
share_packing: number;
share_budget: number;
share_collab: number;
expires_at: string;
}): Promise<void> {
await this.nativeUpdate({ trip: trip_id as number }, data);
}
/**
* SH3 (`createOrUpdate`, TX, new row) — `INSERT INTO share_tokens
* (trip_id, token, created_by, share_map, share_bookings, share_packing,
* share_budget, share_collab, expires_at) VALUES (...)`. `token` is
* generated by the caller (`crypto.randomBytes(24)`), passed straight
* through.
*/
async insertNew(data: {
trip_id: number | string;
token: string;
created_by: number;
share_map: number;
share_bookings: number;
share_packing: number;
share_budget: number;
share_collab: number;
expires_at: string;
}): Promise<void> {
await this.insert({
trip: data.trip_id as number,
token: data.token,
createdByRef: data.created_by,
share_map: data.share_map,
share_bookings: data.share_bookings,
share_packing: data.share_packing,
share_budget: data.share_budget,
share_collab: data.share_collab,
expires_at: data.expires_at,
});
}
/** SH4 (`get`) — `SELECT * FROM share_tokens WHERE trip_id = ?`. */
async findRawByTrip(trip_id: number | string): Promise<ShareTokenRow | undefined> {
return await this.db_().selectFrom('share_tokens').selectAll().where('trip_id', '=', trip_id as number).executeTakeFirst();
}
/**
* SH5 (`remove`) — `DELETE FROM share_tokens WHERE trip_id = ?`.
* **DISTINCT from `deleteByCreator` (UC6)** — a different predicate on the
* same table, never conflated into one method with an optional parameter.
*/
async deleteByTrip(trip_id: number | string): Promise<void> {
await this.nativeDelete({ trip: trip_id as number });
}
/**
* The `token = ?` and `(expires_at IS NULL OR expires_at > <now>)`
* predicate SH6, SH17 and `platform.routes.ts`'s R1-converted pre-init
* `servePhoto` fallback ALL share — built exactly once here, never
* re-derived at any of the three call sites (R5). `token` is compared
* with a plain `=` — no `LIKE`, no case-folding, ever (R5's own
* "the opposite of rule 18" framing). `currentTimestampKysely` (not a
* hand-written `datetime('now')` literal): SQLite's bare `CURRENT_TIMESTAMP`
* keyword renders in the SAME `YYYY-MM-DD HH:MM:SS` UTC text `datetime('now')`
* does, so reusing the existing helper is byte-equivalent to the legacy
* predicate, not an approximation of it.
*/
private validTokenQuery(token: string) {
const platform = this.getEntityManager().getPlatform();
return this.db_()
.selectFrom('share_tokens')
.where('token', '=', token)
.where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', currentTimestampKysely(platform))]));
}
/** SH6 (`getSharedTripData`) — the full row, valid-token predicate above. */
async findValidByToken(token: string): Promise<ShareTokenRow | undefined> {
return await this.validTokenQuery(token).selectAll().executeTakeFirst();
}
/**
* SH17 (`getSharedPlacePhotoKey`) — `trip_id, share_map` only, SAME
* valid-token predicate as SH6/R1.
*/
async findTripAndShareMapByToken(token: string): Promise<{ trip_id: number; share_map: number | null } | undefined> {
return await this.validTokenQuery(token).select(['trip_id', 'share_map']).executeTakeFirst();
}
/**
* R1 (`platform.routes.ts`'s pre-init `servePhoto` share-token fallback,
* SV-PLATFORM1) — `trip_id` only, the SAME valid-token predicate SH6/SH17
* use, built once here and reused, not re-derived at the pre-init call
* site.
*/
async findTripIdByToken(token: string): Promise<{ trip_id: number } | undefined> {
return await this.validTokenQuery(token).select(['trip_id']).executeTakeFirst();
}
/**
* UC6 (`user-cleanup.service.ts`'s GDPR erasure) — `DELETE FROM
* share_tokens WHERE created_by = ?`. **DISTINCT from `deleteByTrip`
* (SH5)** — deletes every link a user CREATED, across every trip,
* regardless of who owns those trips, not the links on one trip.
*/
async deleteByCreator(userId: number): Promise<void> {
await this.nativeDelete({ createdByRef: userId });
}
}
@@ -695,6 +695,22 @@ export class TripsRepository extends TrekRepository<Trips> {
return row ?? null;
}
/**
* `share.service.ts:264` SH7 (`getSharedTripData`) — `SELECT id, title,
* description, start_date, end_date, cover_image, currency FROM trips
* WHERE id = ?`. A different 7-column public projection from
* `TripSummaryProjectionRow`'s 8 above (`cover_image` in, `is_archived`/
* `updated_at` out — a public share-link viewer never sees either). No
* access check here, matching the legacy statement — the caller's own
* `verifyTripAccess`/token validity gates this read.
*/
async findPublicForShare(id: number | string): Promise<TripPublicShareRow | undefined> {
return await this.qb('t')
.select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.cover_image', 't.currency'])
.where('t.id = ?', [id])
.execute<TripPublicShareRow | undefined>('get', false);
}
/**
* `PublicApiService.buildTravellers`'s statement — the owner row (a
* literal `1 AS is_owner`) `UNION ALL` every member row (`0 AS is_owner`),
@@ -921,6 +937,17 @@ export interface TripSummaryProjectionRow {
updated_at: string | null;
}
/** {@link TripsRepository.findPublicForShare}'s projection (SH7). */
export interface TripPublicShareRow {
id: number;
title: string;
description: string | null;
start_date: string | null;
end_date: string | null;
cover_image: string | null;
currency: string | null;
}
/** `PublicApiService.buildTravellers`'s output row. */
export interface TravellerUsernameRow {
username: string;
+5 -2
View File
@@ -14,6 +14,7 @@ import { JourneyShareTokens } from '../../db/entities/JourneyShareTokens.entity'
import { Journeys } from '../../db/entities/Journeys.entity';
import { JourneyEntries } from '../../db/entities/JourneyEntries.entity';
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
import { TokensModule } from '../tokens/tokens.module';
import { AuthPublicController } from './auth-public.controller';
import { AuthController } from './auth.controller';
@@ -92,8 +93,10 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
// 3g Task 4's own addition — `UserCleanupService.cleanupUserReferences`'s
// UC7-10 GDPR-erasure deletes, reached the same cross-domain way as
// McpTokens/OauthTokens above (owned by `nest/journey`, registered here
// for THIS module's own `@InjectRepository` params).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens, BudgetItems, JourneyShareTokens, Journeys, JourneyEntries, JourneyContributors])],
// for THIS module's own `@InjectRepository` params). ShareTokens: Plan
// 3h Task 6's own addition, the SAME cross-domain shape — UC6's
// `share_tokens` erasure delete (owned by `nest/share`).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens, BudgetItems, JourneyShareTokens, Journeys, JourneyEntries, JourneyContributors, ShareTokens])],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
+12 -11
View File
@@ -20,6 +20,8 @@ import { JourneyEntries } from '../../db/entities/JourneyEntries.entity';
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
import type { JourneyContributorsRepository } from '../../db/repositories/JourneyContributors.repository';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
import type { ShareTokensRepository } from '../../db/repositories/ShareTokens.repository';
/**
* Account erasure — everything that has to happen around `DELETE FROM users`
@@ -43,12 +45,12 @@ export class UserCleanupService {
// Plan 3e Task 2 (budget) — additive, UC5 only.
@InjectRepository(BudgetItems) private readonly budgetItemsRepo: BudgetItemsRepository,
// Plan 3g Task 4 (UC7-10) — the genuinely-journey GDPR-erasure deletes.
// UC6 (`share_tokens`, `nest/share`, Plan 3h) is NOT one of these — see
// `cleanupUserReferences`'s own doc comment.
@InjectRepository(JourneyShareTokens) private readonly journeyShareTokensRepo: JourneyShareTokensRepository,
@InjectRepository(Journeys) private readonly journeysRepo: JourneysRepository,
@InjectRepository(JourneyEntries) private readonly journeyEntriesRepo: JourneyEntriesRepository,
@InjectRepository(JourneyContributors) private readonly journeyContributorsRepo: JourneyContributorsRepository,
// Plan 3h Task 6 (UC6, R10) — the genuinely-share GDPR-erasure delete.
@InjectRepository(ShareTokens) private readonly shareTokensRepo: ShareTokensRepository,
) {}
/**
@@ -102,15 +104,14 @@ export class UserCleanupService {
* this plan, so UC4 stays a raw `DatabaseService` call (Plan 3b Task 5
* ruling; inventory §6) — UC5 is already converted (`budgetItemsRepo
* .clearPaidByUser`). `share_tokens` (UC6) belongs to `nest/share`
* (trip-level share links, Plan 3h) — a genuinely different table from
* the four below despite sharing this method and this erasure
* transaction, so it ALSO stays a raw `DatabaseService` call, but for a
* different reason than UC4 (not this plan's table at all, not merely
* "another plan hasn't converted it yet"). `journey_share_tokens`/
* (trip-level share links) and converts here (Plan 3h Task 6, R10) onto
* `ShareTokensRepository.deleteByCreator` — **DISTINCT from
* `ShareService`'s own `remove()`**, which deletes `WHERE trip_id = ?`, a
* different predicate on the same table. `journey_share_tokens`/
* `journeys`/`journey_entries`/`journey_contributors` (UC7-10,
* `nest/journey-share`/`nest/journey-domain`, Plan 3g) ARE this plan's
* own and are converted (Plan 3g Task 4) onto their owning repositories.
* Verbatim otherwise: same statement order, same bounded 4-table
* `nest/journey-share`/`nest/journey-domain`, Plan 3g) were converted
* earlier (Plan 3g Task 4) onto their owning repositories. Verbatim
* otherwise: same statement order, same bounded 5-table
* share_tokens/journey_share_tokens/journeys/journey_entries/
* journey_contributors sequence, `this.budget.removeUserFromBudgetItems`
* unchanged.
@@ -119,7 +120,7 @@ export class UserCleanupService {
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId); // UC4 — Plan 3c
await this.budgetItemsRepo.clearPaidByUser(userId); // UC5 — Plan 3e Task 2, converted.
await this.budget.removeUserFromBudgetItems(userId);
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId); // UC6 — Plan 3h
await this.shareTokensRepo.deleteByCreator(userId); // UC6 — converted (Plan 3h Task 6)
await this.journeyShareTokensRepo.deleteByCreatedBy(userId); // UC7 — converted (Plan 3g Task 4)
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
await this.journeysRepo.deleteOwnedByUser(userId); // UC8 — converted (Plan 3g Task 4)
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
import { reclaimPlaceImage } from '../places/place-image';
import { isUploadedPlaceImage } from '../places/place-image';
import { StorageService } from '../storage/storage.service';
import { Collections } from '../../db/entities/Collections.entity';
import { CollectionsRepository, type CollectionPlaceRow } from '../../db/repositories/Collections.repository';
@@ -945,7 +945,7 @@ export class CollectionsService {
});
if (body.image_url !== undefined && prevImage !== (body.image_url ?? null)) {
await reclaimPlaceImage(this.storage, prevImage);
await this.reclaimPlaceImage(prevImage);
}
await this.notifyCollectionUsers(currentCollection, socketId, 'collections:updated');
@@ -978,12 +978,35 @@ export class CollectionsService {
return this.getPlaceById(placeId);
}
/**
* Delete a custom saved-place-image object once nothing references it any
* more (Plan 3h Task 6, replacing `place-image.ts`'s free function — see
* that file's own doc comment). A trip place and a collection saved-place
* can share the same uploaded file — save-to-collection and copy-to-trip
* copy `image_url` by reference — so this ref-counts across both tables
* before deleting, mirroring `PlacesService.reclaimPlaceImage`'s shape
* exactly: `places` first via `PlacesRepository.existsByImageUrl`
* (`tripPlaces`, already injected), `collection_places` second via
* `CollectionPlacesRepository.existsByImageUrl` (`collectionPlaces`,
* already injected), JS-level `||` short-circuit preserved by early
* return. `path.basename()` keeps the storage name confined to the
* 'places' category. Best-effort: never throws.
*/
private async reclaimPlaceImage(url: string | null | undefined): Promise<void> {
if (!isUploadedPlaceImage(url)) return;
if (await this.tripPlaces.existsByImageUrl(url)) return;
if (await this.collectionPlaces.existsByImageUrl(url)) return;
await this.storage.delete('places', path.basename(url)).catch(() => {
/* best-effort */
});
}
async deletePlace(userId: number, placeId: number, socketId?: string): Promise<void> {
const collectionId = await this.collectionIdOfPlace(placeId);
await this.assertCanDelete(userId, collectionId);
const image = (await this.collectionPlaces.imageUrl(placeId)) ?? null;
await this.collectionPlaces.deleteById(placeId); // CASCADE drops tags. NO photo-cache reclaim.
await reclaimPlaceImage(this.storage, image);
await this.reclaimPlaceImage(image);
await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated');
}
@@ -1007,7 +1030,7 @@ export class CollectionsService {
deleted.push(id);
}
});
for (const image of images) await reclaimPlaceImage(this.storage, image);
for (const image of images) await this.reclaimPlaceImage(image);
for (const cid of touched) await this.notifyCollectionUsers(cid, socketId, 'collections:updated');
return deleted;
}
@@ -1101,7 +1124,7 @@ export class CollectionsService {
await this.assertCanEdit(userId, collectionId);
const prev = (await this.collectionPlaces.imageUrl(placeId)) ?? null;
await this.collectionPlaces.setImageUrl(placeId, imageUrl);
if (prev !== imageUrl) await reclaimPlaceImage(this.storage, prev);
if (prev !== imageUrl) await this.reclaimPlaceImage(prev);
await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated');
return this.getPlaceById(placeId);
}
@@ -9,6 +9,8 @@ import { GooglePlacePhotoMeta } from '../../db/entities/GooglePlacePhotoMeta.ent
import type { GooglePlacePhotoMetaRepository } from '../../db/repositories/GooglePlacePhotoMeta.repository';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { CollectionPlaces } from '../../db/entities/CollectionPlaces.entity';
import type { CollectionPlacesRepository } from '../../db/repositories/CollectionPlaces.repository';
// How long a "no photo for this place" answer stays remembered. Nothing about it
// changes until a photo appears upstream, so it is worth keeping: without it every
@@ -52,10 +54,14 @@ interface CachedPhoto {
* layouts (unset: uploads/photos/google; set: that dir, bare keys), so the
* cache itself is mode-agnostic.
*
* `DatabaseService` stays injected (Plan 3c Task 1, PP6 ruling option 2)
* purely for the `collection_places` half of `isReferenced` — that table is
* Plan 3h's, so it stays a raw statement with a `// Plan 3h` comment until
* then. Every other statement in this file is repository-backed.
* `DatabaseService` was injected (Plan 3c Task 1, PP6 ruling option 2) purely
* for the `collection_places` half of `isReferenced`; Plan 3h Task 6 converts
* that half onto `CollectionPlacesRepository.existsByGoogleIdOrImageUrl`, so
* every statement in this file is now repository-backed — `DatabaseService`
* itself is left injected (out of this task's one-line-swap scope: several
* other domains' test files hand-construct this class positionally) but is
* now unused in the body; flagged for whoever next touches this file's
* constructor to drop.
*/
@Injectable()
export class PlacePhotoCacheService {
@@ -71,6 +77,8 @@ export class PlacePhotoCacheService {
private readonly storage: StorageService,
@InjectRepository(GooglePlacePhotoMeta) private readonly meta: GooglePlacePhotoMetaRepository,
@InjectRepository(Places) private readonly places: PlacesRepository,
// Plan 3h Task 6 (survivors) — additive, SV-PP6's `isReferenced` only.
@InjectRepository(CollectionPlaces) private readonly collectionPlaces: CollectionPlacesRepository,
) {}
private fileName(placeId: string): string {
@@ -215,17 +223,14 @@ export class PlacePhotoCacheService {
* statement spans `places` (this plan's) and `collection_places` (Plan 3h's),
* so it is split into two existence checks, evaluated in the SAME order the
* legacy `UNION ALL … LIMIT 1` would short-circuit in — the `collection_places`
* half only runs when the `places` half comes back false.
* half only runs when the `places` half comes back false. Plan 3h Task 6
* converts the second half onto `CollectionPlacesRepository
* .existsByGoogleIdOrImageUrl`, closing out the carve-out.
*/
private async isReferenced(placeId: string): Promise<boolean> {
const proxyUrl = this.proxyUrl(placeId);
if (await this.places.existsByGoogleIdOrImageUrl(placeId, proxyUrl)) return true;
// Plan 3h: collection_places is not yet repository-backed.
const row = this.db.get(
`SELECT 1 FROM collection_places WHERE google_place_id = ? OR image_url = ? LIMIT 1`,
placeId, proxyUrl,
);
return !!row;
return await this.collectionPlaces.existsByGoogleIdOrImageUrl(placeId, proxyUrl);
}
private async deleteEntry(placeId: string): Promise<void> {
@@ -7,6 +7,7 @@ import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageModule } from '../storage/storage.module';
import { GooglePlacePhotoMeta } from '../../db/entities/GooglePlacePhotoMeta.entity';
import { Places } from '../../db/entities/Places.entity';
import { CollectionPlaces } from '../../db/entities/CollectionPlaces.entity';
/** The marker-photo cache. No controller of its own — maps serves the bytes,
* places and share read through it, and PlacePhotoCacheJob sweeps it nightly.
@@ -17,9 +18,10 @@ import { Places } from '../../db/entities/Places.entity';
*
* MikroOrmModule.forFeature registers GooglePlacePhotoMetaRepository/
* PlacesRepository for PlacePhotoCacheService's @InjectRepository
* constructor (Plan 3c Task 1). */
* constructor (Plan 3c Task 1); Plan 3h Task 6 adds CollectionPlaces for
* SV-PP6's `isReferenced` second existence check. */
@Module({
imports: [AppConfigModule, SchedulingModule, StorageModule, MikroOrmModule.forFeature([GooglePlacePhotoMeta, Places])],
imports: [AppConfigModule, SchedulingModule, StorageModule, MikroOrmModule.forFeature([GooglePlacePhotoMeta, Places, CollectionPlaces])],
providers: [PlacePhotoCacheService, PlacePhotoCacheJob],
exports: [PlacePhotoCacheService],
})
+15 -40
View File
@@ -1,7 +1,3 @@
import path from 'node:path';
import { db } from '../../db/database';
import type { StorageService } from '../storage/storage.service';
const URL_PREFIX = '/uploads/places/';
export function placeImageUrl(filename: string): string {
@@ -12,39 +8,18 @@ export function isUploadedPlaceImage(url: string | null | undefined): url is str
return typeof url === 'string' && url.startsWith(URL_PREFIX);
}
/**
* Delete a custom place-image object once nothing references it any more. A trip
* place and a collection saved-place can share the same uploaded file — save-to-
* collection and copy-to-trip copy image_url by reference — so we ref-count across
* both tables before deleting. basename() keeps the storage name confined to the
* 'places' category, mirroring tripService.deleteOldCover. Best-effort: never
* throws (central key validation rejects a hostile stored value; the catch
* swallows it exactly like the old unlink guard).
*
* Plan 3c Task 4: `PlacesService` no longer calls this free function — it has
* its own converted private `reclaimPlaceImage` method (`places.service.ts`),
* which does the same job through `PlacesRepository.existsByImageUrl` for
* PI1 and one raw `// Plan 3h` statement for PI2. This copy, and the raw `db`
* proxy import it still needs, stay here UNCHANGED because
* `collections.service.ts` (`src/nest/collections/`, Plan 3h's file — the
* `collection_places` table is Plan 3h's) also imports and calls this exact
* function on four of its own call sites, verified by grep
* (`grep -rn "from '.*place-image'" server/src`) — contradicting the
* inventory's stated "every caller is this service" premise
* (`plan3c-sql-inventory.md` §8d). Converting that caller means wiring a
* `PlacesRepository` injection into `CollectionsModule`, a change to an
* unowned domain's DI graph out of this task's file ownership and test
* budget — flagged for whoever next owns `src/nest/collections/**` (Plan 3h)
* to finish, the same `// Plan 3h` carve-out pattern PI2/PP6 already use in
* this cluster.
*/
export async function reclaimPlaceImage(storage: StorageService, url: string | null | undefined): Promise<void> {
if (!isUploadedPlaceImage(url)) return;
const referenced =
db.prepare('SELECT 1 FROM places WHERE image_url = ? LIMIT 1').get(url) ||
db.prepare('SELECT 1 FROM collection_places WHERE image_url = ? LIMIT 1').get(url);
if (referenced) return;
await storage.delete('places', path.basename(url)).catch(() => {
/* best-effort */
});
}
// `reclaimPlaceImage` (the free function this file used to export) is gone
// (Plan 3h Task 6, closing out a 3-plan-old carve-out): `PlacesService` had
// its own converted private `reclaimPlaceImage` since Plan 3c Task 4
// (`places.service.ts`, PI1/PI2 through `PlacesRepository`/
// `CollectionPlacesRepository.existsByImageUrl`); `collections.service.ts`'s
// four call sites (`updatePlace`, `deletePlace`, `deletePlacesMany`,
// `setPlaceImage`) now call a mirroring private `CollectionsService
// .reclaimPlaceImage` method instead, through `PlacesRepository
// .existsByImageUrl` (already injected there, `tripPlaces`) and
// `CollectionPlacesRepository.existsByImageUrl` (already injected,
// `collectionPlaces`) — a whole-tree grep for `from '.*place-image'` (run
// before this deletion) confirmed zero remaining importers of this function;
// `placeImageUrl`/`isUploadedPlaceImage` above still have real importers
// (`collections.controller.ts`, `places.controller.ts`, `places.service.ts`)
// and stay.
+8 -2
View File
@@ -8,6 +8,7 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { Categories } from '../../db/entities/Categories.entity';
import { CollectionPlaces } from '../../db/entities/CollectionPlaces.entity';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { PlacesController } from './places.controller';
import { PlacesService } from './places.service';
@@ -63,8 +64,13 @@ import { MAX_PLACE_IMAGE_SIZE } from '../common/place-image-upload';
// `AssignmentsDomainModule`'s own `MikroOrmModule.forFeature(...)`
// precedent: a `forFeature` registration only reaches providers declared
// in the SAME module. Plan 3c Task 5 adds `CategoriesRepository` for
// PL33's `importKmlPlaces` folder → category lookup.
MikroOrmModule.forFeature([Places, Tags, PlaceRatings, TripMembers, DayAssignments, Trips, Categories, BudgetItems]),
// PL33's `importKmlPlaces` folder → category lookup. Plan 3h Task 6 adds
// `CollectionPlaces` for SV-PI2 (`reclaimPlaceImage`'s second existence
// check) — a read-only cross-domain repository reference, not a
// `CollectionsModule` import, so it creates no cycle (`CollectionsModule`
// does not import `PlacesModule` either — both sides reach the other's
// table only through `forFeature`).
MikroOrmModule.forFeature([Places, Tags, PlaceRatings, TripMembers, DayAssignments, Trips, Categories, BudgetItems, CollectionPlaces]),
],
controllers: [PlacesController],
providers: [PlacesService, PlacesMcp, PlacesRpc],
+10 -6
View File
@@ -49,6 +49,8 @@ import type { CategoriesRepository } from '../../db/repositories/Categories.repo
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { BudgetItems } from '../../db/entities/BudgetItems.entity';
import type { BudgetItemsRepository } from '../../db/repositories/BudgetItems.repository';
import { CollectionPlaces } from '../../db/entities/CollectionPlaces.entity';
import type { CollectionPlacesRepository } from '../../db/repositories/CollectionPlaces.repository';
/** Rows a place delete took down with the nights booked there, for the caller to announce. */
export interface CancelledStays {
@@ -167,6 +169,8 @@ export class PlacesService {
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
// Plan 3e Task 2 (budget) — additive, PL15/PL18/PL22 only.
@InjectRepository(BudgetItems) private readonly budgetItemsRepo: BudgetItemsRepository,
// Plan 3h Task 6 (survivors) — additive, SV-PI2's `reclaimPlaceImage` only.
@InjectRepository(CollectionPlaces) private readonly collectionPlacesRepo: CollectionPlacesRepository,
) {}
/**
@@ -234,9 +238,10 @@ export class PlacesService {
*
* PI1 (`SELECT 1 FROM places WHERE image_url = ? LIMIT 1`) through
* `PlacesRepository.existsByImageUrl`; PI2 (`SELECT 1 FROM collection_places
* WHERE image_url = ? LIMIT 1`, Plan 3h's table) stays one raw statement,
* evaluated only when PI1 is false — reproducing the legacy `UNION ALL …
* LIMIT 1`'s short-circuit by evaluation order, the same pattern
* WHERE image_url = ? LIMIT 1`) through `CollectionPlacesRepository
* .existsByImageUrl` (Plan 3h Task 6, closing out the carve-out), evaluated
* only when PI1 is false — reproducing the legacy `UNION ALL … LIMIT 1`'s
* short-circuit by evaluation order, the same pattern
* `PlacePhotoCacheService.isReferenced` uses for the same two tables
* (Plan 3c Task 1's PP6 ruling). `path.basename()` keeps the storage name
* confined to the 'places' category. Best-effort: never throws (central
@@ -246,9 +251,8 @@ export class PlacesService {
private async reclaimPlaceImage(url: string | null | undefined): Promise<void> {
if (!isUploadedPlaceImage(url)) return;
if (await this.placesRepo.existsByImageUrl(url)) return;
// Plan 3h: collection_places is not yet repository-backed.
const referenced = this.dbs.get('SELECT 1 FROM collection_places WHERE image_url = ? LIMIT 1', url);
if (referenced) return;
// PI2 (Plan 3h Task 6) — `CollectionPlacesRepository.existsByImageUrl`.
if (await this.collectionPlacesRepo.existsByImageUrl(url)) return;
await this.storage.delete('places', path.basename(url)).catch(() => {
/* best-effort */
});
+27 -16
View File
@@ -10,6 +10,8 @@ import { StorageService } from '../storage/storage.service';
import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } from '../storage/storage.types';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
import type { ShareTokensRepository } from '../../db/repositories/ShareTokens.repository';
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
@@ -74,7 +76,7 @@ export function storageStaticHandler(storage: StorageService, category: StorageC
};
}
async function servePhoto(storage: StorageService, req: Request, res: Response, users: UsersRepository): Promise<void> {
async function servePhoto(storage: StorageService, req: Request, res: Response, users: UsersRepository, shareTokens: ShareTokensRepository): Promise<void> {
const safeName = path.basename(req.params.filename);
// Parity: after basename(), the old resolve()+startsWith guard could only
// fire when the remaining segment was '..' — keep that exact 403.
@@ -116,9 +118,7 @@ async function servePhoto(storage: StorageService, req: Request, res: Response,
res.status(401).send('Authentication required');
return;
}
const share = db
.prepare("SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))")
.get(rawToken) as { trip_id: number } | undefined;
const share = await shareTokens.findTripIdByToken(rawToken);
if (!share || share.trip_id !== photo.trip_id) {
res.status(401).send('Authentication required');
return;
@@ -136,17 +136,28 @@ async function servePhoto(storage: StorageService, req: Request, res: Response,
* per-request EntityManager fork, forever, not just during a boot window.
* `verifyJwtAndLoadUser` (`jwt-verify.ts`, JV1) now reads `users` through
* `UsersRepository` (Plan 3b Task 1) rather than the legacy `db` proxy — the
* exact case Task 0's wrap was built for. `orm.em.getRepository(Users)` is
* resolved inside the arrow passed to `withRequestContext` below: `orm.em`
* is the global, context-resolving EntityManager, and MikroORM's repository
* object holds a reference to that same proxy rather than a snapshot, so a
* query issued through it later resolves whatever `AsyncLocalStorage`
* context is active AT QUERY TIME — not at the moment `getRepository()` was
* called. What actually gates this is `withRequestContext` wrapping the
* whole `servePhoto(...)` call (query included): removing the wrapper
* entirely reproduces Task 0's `ValidationError: Using global EntityManager
* instance...` (verified directly — mutation-proof in `task-1-report.md`),
* exactly the failure `PHOTOCTX-002`/`SEAM-002` guard against.
* exact case Task 0's wrap was built for. The anonymous share-token fallback
* (used when no JWT is present) now reads `share_tokens` through
* `ShareTokensRepository.findTripIdByToken` the SAME way (Plan 3h Task 6, R1
* — the plan's single highest-severity finding: this branch ran unconverted,
* against the legacy `db` proxy, for three plans after the JWT half above
* was fixed). Both `orm.em.getRepository(Users)` and
* `orm.em.getRepository(ShareTokens)` are resolved inside the arrow passed
* to `withRequestContext` below: `orm.em` is the global, context-resolving
* EntityManager, and MikroORM's repository object holds a reference to that
* same proxy rather than a snapshot, so a query issued through it later
* resolves whatever `AsyncLocalStorage` context is active AT QUERY TIME —
* not at the moment `getRepository()` was called. What actually gates this
* is `withRequestContext` wrapping the whole `servePhoto(...)` call (both
* repository reads included): removing the wrapper entirely reproduces Task
* 0's `ValidationError: Using global EntityManager instance...` (verified
* directly — mutation-proof in `task-1-report.md`), exactly the failure
* `PHOTOCTX-002`/`SEAM-002` guard against. The sibling `photos` table read
* (`db.prepare('SELECT trip_id FROM photos ...')`, a few lines into
* `servePhoto` below) stays on the legacy `db` proxy — a different domain's
* table, not this plan's — but its `trip_id` MATCH against the converted
* `share` read is the handler's whole authorization and is untouched: only
* the `share_tokens` lookup itself moved, not the comparison built on it.
*
* Optional at the type level only, the same shape `TrekWsAdapter` uses for
* its own D6 wrapper (`src/nest/realtime/trek-ws.adapter.ts`): `bootstrap.ts`
@@ -191,7 +202,7 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
next(new Error('applyPlatformUploads: no MikroORM available to build a request context for /uploads/photos/*'));
return;
}
return withRequestContext(orm, () => servePhoto(storage, req, res, orm.em.getRepository(Users))).catch(next);
return withRequestContext(orm, () => servePhoto(storage, req, res, orm.em.getRepository(Users), orm.em.getRepository(ShareTokens))).catch(next);
});
// Block direct access to /uploads/files
+23 -8
View File
@@ -11,16 +11,31 @@ import { StorageModule } from '../storage/storage.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Reservations } from '../../db/entities/Reservations.entity';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { Days } from '../../db/entities/Days.entity';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import { DayNotes } from '../../db/entities/DayNotes.entity';
import { Places } from '../../db/entities/Places.entity';
import { PackingItems } from '../../db/entities/PackingItems.entity';
import { BudgetItems } from '../../db/entities/BudgetItems.entity';
import { Categories } from '../../db/entities/Categories.entity';
import { CollabMessages } from '../../db/entities/CollabMessages.entity';
// `MikroOrmModule.forFeature([Reservations])` registers `ReservationsRepository`
// for `ShareService`'s `@InjectRepository` constructor param (Plan 3d Task 4 —
// its four reads on `reservations`/`reservation_endpoints`/
// `reservation_day_positions`/`day_accommodations` convert onto the same
// repository `ReservationsModule` owns, without importing that module itself:
// this file stays a leaf the way `PublicApiModule`'s own `Trips`-only
// `forFeature` already does for the same reason).
// `MikroOrmModule.forFeature([Reservations, ...])` registers every repository
// `ShareService`'s `@InjectRepository` constructor params need (Plan 3d Task 4
// already registered `Reservations`; Plan 3h Task 6 adds `ShareTokens` — this
// file's own table — plus the nine cross-domain tables its public-share reads
// (SH7-9,11-16) touch, without importing each owning module: this file stays a
// leaf the way `PublicApiModule`'s own `Trips`-only `forFeature` already does
// for the same reason).
@Module({
imports: [McpSharedModule, SettingsModule, PermissionsModule, QueryHelpersModule, AuthModule, PlacePhotosModule, StorageModule, MikroOrmModule.forFeature([Reservations])],
imports: [
McpSharedModule, SettingsModule, PermissionsModule, QueryHelpersModule, AuthModule, PlacePhotosModule, StorageModule,
MikroOrmModule.forFeature([
Reservations, ShareTokens, Trips, Days, DayAssignments, DayNotes, Places, PackingItems, BudgetItems, Categories, CollabMessages,
]),
],
controllers: [TripShareController, SharedController],
providers: [ShareService, ShareMcp],
})
+81 -68
View File
@@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../database/database.service';
import type { TripAccess } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
@@ -11,6 +9,26 @@ import { UnitOfWork } from '../database/unit-of-work';
import type { User } from '../../types';
import { Reservations } from '../../db/entities/Reservations.entity';
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
import type { ShareTokensRepository } from '../../db/repositories/ShareTokens.repository';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository, TripAccess } from '../../db/repositories/Trips.repository';
import { Days } from '../../db/entities/Days.entity';
import type { DaysRepository } from '../../db/repositories/Days.repository';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
import { DayNotes } from '../../db/entities/DayNotes.entity';
import type { DayNotesRepository } from '../../db/repositories/DayNotes.repository';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { PackingItems } from '../../db/entities/PackingItems.entity';
import type { PackingItemsRepository } from '../../db/repositories/PackingItems.repository';
import { BudgetItems } from '../../db/entities/BudgetItems.entity';
import type { BudgetItemsRepository } from '../../db/repositories/BudgetItems.repository';
import { Categories } from '../../db/entities/Categories.entity';
import type { CategoriesRepository } from '../../db/repositories/Categories.repository';
import { CollabMessages } from '../../db/entities/CollabMessages.entity';
import type { CollabMessagesRepository } from '../../db/repositories/CollabMessages.repository';
type Trip = TripAccess;
@@ -50,9 +68,10 @@ export interface ShareTokenInfo {
}
/**
* Public share links — the legacy shareService SQL folded in over the injected
* DatabaseService. Trip access and the 'share_manage' permission gate
* create/delete; the shared read is public.
* Public share links — `share_tokens` and its cross-domain public reads
* through repositories (Plan 3h Task 6; 3d Task 4 already converted the
* reservations-related reads). Trip access and the 'share_manage' permission
* gate create/delete; the shared read is public.
*/
/**
* What a place shows the public: where it is, what it is, how to reach it.
@@ -61,12 +80,12 @@ export interface ShareTokenInfo {
* Google identifiers, the routing bookkeeping and the fill figures a road trip
* keeps for itself. `notes` stays — it is the note somebody wrote to be read
* on the plan, and the plan is what a link shares.
*
* The exact column list now lives as {@link PlacesRepository.listPublicForShare}'s
* own docstring (Plan 3h Task 6) — this file no longer spells the SQL, so
* keeping a second copy of the allow-list here would drift from the one the
* query actually runs.
*/
const PUBLIC_PLACE_COLUMNS = [
'id', 'trip_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency',
'place_time', 'end_time', 'duration_minutes', 'notes', 'image_url', 'website', 'phone',
'transport_mode', 'created_at', 'updated_at',
].map(c => `p.${c}`).join(', ');
// What a booking/stay shows the public — never the confirmation number, the
// import trail, or who is travelling — now lives as the exact column list in
@@ -145,17 +164,27 @@ export function publicHttpUrl(value: unknown): string | null {
@Injectable()
export class ShareService {
constructor(
private readonly dbs: DatabaseService,
private readonly settings: SettingsService,
private readonly permissions: PermissionsService,
private readonly queryHelpers: QueryHelpersService,
private readonly photoCache: PlacePhotoCacheService,
private readonly uow: UnitOfWork,
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
@InjectRepository(ShareTokens) private readonly shareTokens: ShareTokensRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@InjectRepository(Days) private readonly days: DaysRepository,
@InjectRepository(DayAssignments) private readonly dayAssignments: DayAssignmentsRepository,
@InjectRepository(DayNotes) private readonly dayNotes: DayNotesRepository,
@InjectRepository(Places) private readonly places: PlacesRepository,
@InjectRepository(PackingItems) private readonly packingItems: PackingItemsRepository,
@InjectRepository(BudgetItems) private readonly budgetItems: BudgetItemsRepository,
@InjectRepository(Categories) private readonly categories: CategoriesRepository,
@InjectRepository(CollabMessages) private readonly collabMessages: CollabMessagesRepository,
) {}
async verifyTripAccess(tripId: string, userId: number) {
return await this.dbs.canAccessTrip(tripId, userId);
/** SH-AP1 — the same `TripsRepository.findAccessible` every other access-primitive call in this plan reuses. */
async verifyTripAccess(tripId: string, userId: number): Promise<Trip | undefined> {
return await this.trips.findAccessible(tripId, userId);
}
async canManage(trip: Trip, user: User): Promise<boolean> {
@@ -182,20 +211,31 @@ export class ShareService {
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
return await this.uow.transactional(async () => {
const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId);
const existing = await this.shareTokens.findTokenByTrip(tripId);
if (existing) {
this.dbs.run(
'UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ?, expires_at = ? WHERE trip_id = ?',
share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt, tripId,
);
await this.shareTokens.updateFlagsByTrip(tripId, {
share_map: share_map ? 1 : 0,
share_bookings: share_bookings ? 1 : 0,
share_packing: share_packing ? 1 : 0,
share_budget: share_budget ? 1 : 0,
share_collab: share_collab ? 1 : 0,
expires_at: expiresAt,
});
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
this.dbs.run(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId, token, userId, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt,
);
await this.shareTokens.insertNew({
trip_id: tripId,
token,
created_by: userId,
share_map: share_map ? 1 : 0,
share_bookings: share_bookings ? 1 : 0,
share_packing: share_packing ? 1 : 0,
share_budget: share_budget ? 1 : 0,
share_collab: share_collab ? 1 : 0,
expires_at: expiresAt,
});
return { token, created: true };
});
}
@@ -204,11 +244,11 @@ export class ShareService {
* Returns share token info for a trip, or null if no share link exists.
*/
async get(tripId: string): Promise<ShareTokenInfo | null> {
const row = this.dbs.get<any>('SELECT * FROM share_tokens WHERE trip_id = ?', tripId);
const row = await this.shareTokens.findRawByTrip(tripId);
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
created_at: row.created_at ?? '',
share_map: !!row.share_map,
share_bookings: !!row.share_bookings,
share_packing: !!row.share_packing,
@@ -221,7 +261,7 @@ export class ShareService {
* Deletes the share token for a trip.
*/
async remove(tripId: string): Promise<void> {
this.dbs.run('DELETE FROM share_tokens WHERE trip_id = ?', tripId);
await this.shareTokens.deleteByTrip(tripId);
}
/**
@@ -251,19 +291,13 @@ export class ShareService {
}
async getSharedTripData(token: string): Promise<Record<string, any> | null> {
const shareRow = this.dbs.get<any>(
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
token,
);
const shareRow = await this.shareTokens.findValidByToken(token);
if (!shareRow) return null;
const tripId = shareRow.trip_id;
// Trip
const trip = this.dbs.get(
'SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?',
tripId,
);
const trip = await this.trips.findPublicForShare(tripId);
if (!trip) return null;
const permissions = {
@@ -280,31 +314,17 @@ export class ShareService {
let dayNotes: Record<number, any[]> = {};
let places: any[] = [];
if (permissions.share_map) {
days = this.dbs.all<any>('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId);
days = await this.days.listByTrip(tripId);
const dayIds = days.map(d => d.id);
if (dayIds.length > 0) {
const ph = dayIds.map(() => '?').join(',');
const allAssignments = this.dbs.all<any>(`
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,
p.website, p.phone,
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
`, ...dayIds);
const allAssignments = await this.dayAssignments.listPublicForShare(dayIds);
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
const placeIds = [...new Set(allAssignments.map((a) => a.place_id))];
const tagsByPlace = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
const byDay: Record<number, any[]> = {};
for (const a of allAssignments as any[]) {
for (const a of allAssignments) {
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,
@@ -325,9 +345,9 @@ export class ShareService {
}
assignments = byDay;
const allNotes = this.dbs.all<any>(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`, ...dayIds);
const allNotes = await this.dayNotes.listByDayIds(dayIds);
const notesByDay: Record<number, any[]> = {};
for (const n of allNotes as any[]) {
for (const n of allNotes) {
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
notesByDay[n.day_id].push(n);
}
@@ -337,11 +357,7 @@ export class ShareService {
// Named columns, not p.*: the pool used to travel whole, which put the
// owner's booking notes on the place, the Google ids and the import
// bookkeeping in front of anybody holding the link (#2320).
places = this.dbs.all<any>(`
SELECT ${PUBLIC_PLACE_COLUMNS}, 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
`, tripId).map((p) => ({
places = (await this.places.listPublicForShare(tripId)).map((p) => ({
...p,
image_url: rewritePlacePhotoUrl(p.image_url, token),
website: publicHttpUrl(p.website),
@@ -383,21 +399,21 @@ export class ShareService {
// 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 = permissions.share_packing
? this.dbs.all('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC', tripId)
? await this.packingItems.listPublicForShare(tripId)
: [];
// Budget
const budget = permissions.share_budget
? this.dbs.all('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC', tripId)
? await this.budgetItems.listPublicForShare(tripId)
: [];
// Categories are a shared global pool (the authed /api/categories list is
// equally unscoped), so the public payload returns them all too.
const categories = this.dbs.all('SELECT * FROM categories');
const categories = await this.categories.listAllUnordered();
// Collab messages (only if owner chose to share)
const collabMessages = permissions.share_collab
? this.dbs.all('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', tripId)
? await this.collabMessages.listPublicForShare(tripId)
: [];
// Display currency the share owner sees in their Costs view. A public viewer has
@@ -447,10 +463,7 @@ export class ShareService {
* mirroring the authenticated bytes endpoint.
*/
async getSharedPlacePhotoKey(token: string, placeId: string): Promise<string | null> {
const shareRow = this.dbs.get<{ trip_id: string; share_map: number }>(
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
token,
);
const shareRow = await this.shareTokens.findTripAndShareMapByToken(token);
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
@@ -458,8 +471,8 @@ export class ShareService {
if (!shareRow.share_map) return null;
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
const place = this.dbs.get('SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?', shareRow.trip_id, expectedUrl);
if (!place) return null;
const exists = await this.places.existsByTripAndImageUrl(shareRow.trip_id, expectedUrl);
if (!exists) return null;
return this.photoCache.serveKey(placeId);
}