Merge remote-tracking branch 'origin/feat/storage-driver' into feat/storage-driver

This commit is contained in:
jubnl
2026-08-20 09:14:25 +02:00
146 changed files with 31120 additions and 852 deletions
+38 -6
View File
@@ -3981,17 +3981,49 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
}
},
/*
* TREK Studio books (#1973).
*
* One row per book, the document itself stored as JSON. A book is a
* document rather than a graph of records: the editor loads it whole, the
* renderer prints it whole, and nothing ever queries "which books contain a
* heart-shaped frame". Normalising spreads and elements into tables would
* buy queries nobody makes and cost a join on every open plus a schema
* migration for every new element kind.
*
* `updated_at` and `version` are what make concurrent editing possible:
* the version increments on every write, and a client that saves against a
* version it did not read gets told rather than silently overwriting.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS journey_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
document TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id);
`);
},
// 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 (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.
//
// Appended LAST again, and for the same reason as the note this replaces:
// the array is index-addressed against schema_version, so a slot that has
// shipped in dev keeps the index it shipped with and anything from this
// branch goes after it. A database that already ran this migration at its
// pre-merge index replays it harmlessly (the LIKE guard) but skips whatever
// now occupies that index. Acceptable only because this branch has never
// been published; never do this with a released slot.
() => {
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
},
@@ -0,0 +1,218 @@
import { Injectable } from '@nestjs/common';
import type { BookRecord, BookSummary } from '@trek/shared';
import { normalizeBookDocument } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { JourneyDomainService } from './journey-domain.service';
/**
* Storing TREK Studio books.
*
* ── Access ───────────────────────────────────────────────────────────────
*
* A book belongs to its journey, so it inherits the journey's access exactly —
* every contributor can open and edit it. There is no separate book permission
* and there should not be: a second access model over the same object is how
* two rules end up disagreeing about who may do what.
*
* ── Concurrency ──────────────────────────────────────────────────────────
*
* Optimistic, on a version column. Every save states the version it was made
* against; the update only lands if that is still the current one, and the
* whole thing is one statement so two saves arriving together cannot both
* decide they are first. The loser is told, and told *with* the current record,
* so it can show the other version rather than only announcing one exists.
*
* The alternative — last write wins — is not a simpler version of this. It is
* the same thing with the failure moved somewhere nobody sees it.
*/
@Injectable()
export class JourneyBookService {
constructor(
private readonly db: DatabaseService,
private readonly journey: JourneyDomainService,
) {}
/** Null when the journey does not exist or the user cannot reach it. */
private canAccess(journeyId: number, userId: number): boolean {
return !!this.journey.canAccessJourney(journeyId, userId);
}
/**
* Whether this user may open the journey's book at all.
*
* Public so the controller can tell "no book yet" from "no journey" without
* running a query built for something else.
*/
canOpen(journeyId: number, userId: number): boolean {
return this.canAccess(journeyId, userId);
}
private toRecord(row: BookRow): BookRecord {
return {
id: row.id,
journeyId: row.journey_id,
title: row.title,
version: row.version,
updatedAt: row.updated_at,
updatedBy: row.updated_by,
// Never thrown at the caller: a document that cannot be parsed still has
// to open, or a single drifted field would lock someone out of their own
// book. normalizeBookDocument drops what it cannot read.
document: normalizeBookDocument(safeParse(row.document)),
};
}
listBooks(journeyId: number, userId: number): BookSummary[] | null {
if (!this.canAccess(journeyId, userId)) return null;
const rows = this.db
.prepare(`
SELECT id, journey_id, title, version, updated_at, updated_by
FROM journey_books
WHERE journey_id = ?
ORDER BY updated_at DESC, id DESC
`)
.all(journeyId) as Omit<BookRow, 'document'>[];
return rows.map(r => ({
id: r.id,
journeyId: r.journey_id,
title: r.title,
version: r.version,
updatedAt: r.updated_at,
updatedBy: r.updated_by,
}));
}
/**
* The journey's book.
*
* One per journey for now — the table allows more, because a second book of
* the same trip is an obvious thing to want and adding a column later is
* harder than not needing to.
*/
getBook(journeyId: number, userId: number): BookRecord | null {
if (!this.canAccess(journeyId, userId)) return null;
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
FROM journey_books
WHERE journey_id = ?
ORDER BY id ASC
LIMIT 1
`)
.get(journeyId) as BookRow | undefined;
return row ? this.toRecord(row) : null;
}
/**
* Create or update, against a version.
*
* Returns the saved record, or `{ conflict }` when the base version has
* moved. Throwing would be the obvious shape and the wrong one: a conflict is
* an ordinary outcome of two people working, not an exception.
*/
saveBook(
journeyId: number,
userId: number,
input: { title: string; document: unknown; baseVersion?: number },
): { record: BookRecord } | { conflict: BookRecord } | null {
if (!this.canAccess(journeyId, userId)) return null;
const document = JSON.stringify(normalizeBookDocument(input.document));
const existing = this.db
.prepare('SELECT id, version FROM journey_books WHERE journey_id = ? ORDER BY id ASC LIMIT 1')
.get(journeyId) as { id: number; version: number } | undefined;
if (!existing) {
const result = this.db
.prepare(`
INSERT INTO journey_books (journey_id, title, document, version, created_by, updated_by, updated_at)
VALUES (?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)
`)
.run(journeyId, input.title, document, userId, userId);
return { record: this.byId(Number(result.lastInsertRowid))! };
}
/*
* The version goes in the WHERE clause rather than being checked first.
* Read-then-write leaves a window between the two in which another save can
* land, and SQLite gives no guarantee across two statements — one UPDATE
* that matches on the version cannot lose that race with itself.
*
* A save with no base version is a first write from a client that has not
* loaded one; it is allowed to take the current version, since refusing it
* would break "open Studio and start editing" for the second person to
* arrive.
*/
const base = input.baseVersion ?? existing.version;
const result = this.db
.prepare(`
UPDATE journey_books
SET title = ?, document = ?, version = version + 1,
updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND version = ?
`)
.run(input.title, document, userId, existing.id, base);
if (result.changes === 0) {
return { conflict: this.byId(existing.id)! };
}
return { record: this.byId(existing.id)! };
}
deleteBook(journeyId: number, userId: number): boolean | null {
if (!this.canAccess(journeyId, userId)) return null;
const result = this.db
.prepare('DELETE FROM journey_books WHERE journey_id = ?')
.run(journeyId);
return result.changes > 0;
}
/**
* Tell the other editors the book moved on.
*
* The version travels, not the document. Everyone with it open needs to know
* theirs is behind — one integer — and can ask for the rest if they want it.
* Broadcasting a few hundred kilobytes of JSON on every autosave would make
* the notification the size of the thing being edited.
*
* The saver is excluded by socket id, the same way every other TREK mutation
* does it, so the client that just saved does not process its own change.
*/
broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
this.journey.broadcastJourneyEvent(
journeyId,
'journey:book:saved',
{ version: record.version, savedBy: userId },
socketId,
);
}
private byId(id: number): BookRecord | null {
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
FROM journey_books WHERE id = ?
`)
.get(id) as BookRow | undefined;
return row ? this.toRecord(row) : null;
}
}
interface BookRow {
id: number;
journey_id: number;
title: string;
document: string;
version: number;
updated_at: string | null;
updated_by: number | null;
}
/** JSON that will not parse is an empty document, never an exception. */
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
@@ -3,8 +3,44 @@ import { avatarUrl } from '../common/avatarUrl';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import type { JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
/**
* English country names for whatever codes a journey turned up.
*
* `Intl.DisplayNames` rather than a bundled table or the admin-0 properties:
* Node has the CLDR data already, the names it gives are the ones every other
* piece of software shows, and parsing 30MB of boundary GeoJSON to read a
* `NAME` field would be an absurd way to learn that IS is Iceland.
*
* English on purpose — the client re-resolves these into the reader's language
* when it places the element, and a book needs the country's name in the
* language the book is written in, not the language of whoever's server it is.
*/
function regionNames(): Intl.DisplayNames | null {
try {
return new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
// A Node built without full ICU has no region names. Codes are a poor
// label but a working one, and a book page is not worth a 500 over.
return null;
}
}
function countryNamesFor(points: { country: string | null }[]): Record<string, string> {
const out: Record<string, string> = {};
const display = regionNames();
for (const p of points) {
if (!p.country) continue;
const code = p.country.toUpperCase();
if (out[code]) continue;
out[code] = (display?.of(code) ?? code) || code;
}
return out;
}
@@ -50,7 +86,14 @@ export class JourneyDomainService {
private broadcastJourneyEvent(
/**
* Tell everyone on a journey that something changed.
*
* Public because the book service needs the same audience — who can see a
* journey is one question with one answer, and a second copy of this walk
* over contributors and owner would be a second answer waiting to disagree.
*/
broadcastJourneyEvent(
journeyId: number,
// Typed against the shared WS registry rather than left as `string`: the
// legacy broadcastToUser took anything, so a typo'd event name shipped as a
@@ -813,6 +856,138 @@ export class JourneyDomainService {
return tracks;
}
/**
* What this journey adds up to — the figures TREK Studio prints on a page.
*
* Derived, never stored: the trips added to the journey carry the places and
* the dates, the journey carries the entries and the photographs, and the
* numbers fall out of those. Nothing here is a column anyone has to keep in
* step.
*
* ── Where the route comes from ─────────────────────────────────────────
*
* The journey's own entries when they carry coordinates, because an entry is
* something that happened somewhere on a day and that is exactly what a route
* is made of. A journey assembled from trips that nobody has written up yet
* has no such entries, and falls back to the places on those trips — which is
* the same route, told by the itinerary instead of by the traveller.
*
* Mixing the two would double back on itself: an entry written about a place
* that is also on the trip would be two stops at one location, and the
* distance would count the leg twice.
*
* ── Where the countries come from ──────────────────────────────────────
*
* `place_regions` first: Atlas already resolves a place to its country and
* caches it there, and reading a cache beats repeating a point-in-polygon
* test against 4MB of boundaries. What is missing falls through to
* `getCountryFromCoords`, which is the same answer computed rather than
* remembered. That import is a plain function from a module built to have no
* DI and no Nest edges, so it costs this domain no coupling to Atlas.
*/
journeyStats(journeyId: number, userId: number): JourneyStats | null {
if (!this.canAccessJourney(journeyId, userId)) return null;
const entryRows = this.db.prepare(`
SELECT id, title, location_name, location_lat, location_lng, entry_date
FROM journey_entries
WHERE journey_id = ?
ORDER BY entry_date ASC, sort_order ASC, id ASC
`).all(journeyId) as {
id: number; title: string | null; location_name: string | null;
location_lat: number | null; location_lng: number | null; entry_date: string | null;
}[];
const tripDates = this.db.prepare(`
SELECT t.start_date AS start, t.end_date AS end
FROM journey_trips jt JOIN trips t ON t.id = jt.trip_id
WHERE jt.journey_id = ?
`).all(journeyId) as { start: string | null; end: string | null }[];
// Ordered the way the trip is walked: by day, then by the order within it.
// A place can be assigned to more than one day (a hotel across three nights
// is one place, three assignments), so the join is aggregated back down to
// one row per place at its earliest day — otherwise the route would visit
// the hotel three times and the distance would count those legs.
const placeRows = this.db.prepare(`
SELECT p.id, p.name, p.lat, p.lng,
MIN(d.date) AS day,
MIN(da.order_index) AS ord
FROM journey_trips jt
JOIN places p ON p.trip_id = jt.trip_id
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON d.id = da.day_id
WHERE jt.journey_id = ?
GROUP BY p.id
ORDER BY day IS NULL, day ASC, ord ASC, p.id ASC
`).all(journeyId) as {
id: number; name: string | null; lat: number | null; lng: number | null;
day: string | null; ord: number | null;
}[];
const placeCount = this.db.prepare(`
SELECT COUNT(*) AS n FROM journey_trips jt
JOIN places p ON p.trip_id = jt.trip_id
WHERE jt.journey_id = ?
`).get(journeyId) as { n: number };
const photoCount = this.db
.prepare('SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?')
.get(journeyId) as { n: number };
// The cached country per place, for whichever of them Atlas has seen.
const cachedCountry = new Map<number, string>();
const placeIds = placeRows.map(p => p.id);
for (let i = 0; i < placeIds.length; i += 400) {
const chunk = placeIds.slice(i, i + 400);
if (!chunk.length) continue;
const rows = this.db
.prepare(`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${chunk.map(() => '?').join(',')})`)
.all(...chunk) as { place_id: number; country_code: string }[];
for (const r of rows) if (r.country_code) cachedCountry.set(r.place_id, r.country_code.toUpperCase());
}
const countryAt = (lat: number, lng: number, placeId?: number): string | null => {
if (placeId != null) {
const cached = cachedCountry.get(placeId);
if (cached) return cached;
}
return getCountryFromCoords(lat, lng);
};
const fromEntries: StatsInputPoint[] = entryRows
.filter(e => Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng))
.map(e => ({
lat: e.location_lat as number,
lng: e.location_lng as number,
label: e.title || e.location_name || '',
date: e.entry_date ?? null,
country: countryAt(e.location_lat as number, e.location_lng as number),
}));
const points: StatsInputPoint[] = fromEntries.length
? fromEntries
: placeRows
.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.map(p => ({
lat: p.lat as number,
lng: p.lng as number,
label: p.name || '',
date: p.day ?? null,
country: countryAt(p.lat as number, p.lng as number, p.id),
}));
return computeJourneyStats({
journeyId,
points,
entries: entryRows.length,
photos: photoCount?.n ?? 0,
places: placeCount?.n ?? 0,
tripDates,
countryNames: countryNamesFor(points),
});
}
listEntries(journeyId: number, userId: number) {
if (!this.canAccessJourney(journeyId, userId)) return null;
@@ -65,6 +65,9 @@ export class JourneyPublicController {
throw new HttpException({ error: 'Not found' }, 404);
}
res.set('Cache-Control', 'public, max-age=86400');
// The storage layer serves it, whichever driver is configured. dev's fix
// for the absolute-path 404 under the Nest ExpressAdapter does not apply
// here any more: nothing on this path calls sendFile.
await this.storage.sendToResponse('journey', name, res);
return;
}
+205
View File
@@ -0,0 +1,205 @@
import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint } from '@trek/shared';
/**
* What a journey adds up to — the arithmetic, with no database and no Nest.
*
* Plain module on the same footing as `atlas-geo.ts` and `maps.helpers.ts`: the
* service fetches rows and hands them here, which keeps every rule in this file
* testable against a literal array instead of a fixture database. It is also
* the only place these definitions live, so "what counts as a day" has one
* answer rather than one per caller.
*/
/** Mean Earth radius in metres (IUGG). */
const EARTH_RADIUS = 6371008.8;
/** How many stops a printed route can usefully resolve. */
export const MAX_ROUTE_POINTS = 400;
export interface StatsInputPoint {
lat: number;
lng: number;
label: string;
/** ISO date, when the stop has one. */
date: string | null;
country: string | null;
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/**
* Great-circle metres between two coordinates.
*
* Not a driving distance: there is no routing engine here, consecutive stops
* are not necessarily joined by a road, and a book that prints a road distance
* it never measured is worse than one that says "as the crow flies".
*/
export function haversine(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const dLat = rad(b.lat - a.lat);
const dLng = rad(b.lng - a.lng);
const s = Math.sin(dLat / 2) ** 2
+ Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.min(1, Math.sqrt(s)));
}
/** Metres along the route, in the order given. */
export function routeDistance(points: { lat: number; lng: number }[]): number {
let total = 0;
for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]);
return total;
}
/**
* Metres from the first stop to whichever is furthest from it.
*
* Polarsteps calls this "furthest point from home" and measures from where the
* trip started, which is the only definition that needs no extra information —
* TREK does not know where anyone lives, and asking would be a worse book for
* the one fact it adds.
*/
export function furthestFromStart(points: { lat: number; lng: number }[]): number {
if (points.length < 2) return 0;
const start = points[0];
let max = 0;
for (let i = 1; i < points.length; i++) {
const d = haversine(start, points[i]);
if (d > max) max = d;
}
return max;
}
/** The `YYYY-MM-DD` part of an ISO date or datetime, or null if it is neither. */
export function isoDay(value: string | null | undefined): string | null {
if (!value) return null;
const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(value));
return m ? m[1] : null;
}
/**
* Calendar days spanned, inclusive at both ends.
*
* A trip that starts and ends the same day is one day, not zero — the figure a
* book prints under "DAYS" is how long you were away, and nobody describes a
* day trip as lasting no days.
*/
export function daysBetween(start: string | null, end: string | null): number {
const a = isoDay(start);
const b = isoDay(end);
if (!a || !b) return 0;
const from = Date.parse(`${a}T00:00:00Z`);
const to = Date.parse(`${b}T00:00:00Z`);
if (!Number.isFinite(from) || !Number.isFinite(to)) return 0;
return Math.max(1, Math.round((to - from) / 86_400_000) + 1);
}
/**
* Thin a route down to a point budget, keeping the ends.
*
* Evenly spaced rather than clustered: dropping a run from the middle would
* cut the route's shape out, while every n-th stop keeps it and only loses
* detail no printed line could show anyway.
*/
export function thinRoute<T>(points: T[], limit = MAX_ROUTE_POINTS): T[] {
if (points.length <= limit) return points;
const out: T[] = [];
const step = (points.length - 1) / (limit - 1);
for (let i = 0; i < limit; i++) out.push(points[Math.round(i * step)]);
return out;
}
/**
* Countries in the order they were first reached.
*
* Visit order, not alphabetical and not by size: the country page of a travel
* book retells the route, and "Mexico, Belize, Guatemala" is the trip while
* "Belize, Guatemala, Mexico" is a filing cabinet. Countries whose stops carry
* no date fall to the end, in the order they appeared.
*/
export function collectCountries(
points: StatsInputPoint[],
names: Record<string, string>,
): JourneyStatsCountry[] {
const seen = new Map<string, { code: string; places: number; firstVisit: string | null; order: number }>();
points.forEach((p, i) => {
if (!p.country) return;
const code = p.country.toUpperCase();
const day = isoDay(p.date);
const entry = seen.get(code);
if (!entry) {
seen.set(code, { code, places: 1, firstVisit: day, order: i });
return;
}
entry.places += 1;
if (day && (!entry.firstVisit || day < entry.firstVisit)) entry.firstVisit = day;
});
return [...seen.values()]
.sort((a, b) => {
if (a.firstVisit && b.firstVisit && a.firstVisit !== b.firstVisit) {
return a.firstVisit < b.firstVisit ? -1 : 1;
}
if (a.firstVisit && !b.firstVisit) return -1;
if (!a.firstVisit && b.firstVisit) return 1;
return a.order - b.order;
})
.map(c => ({
code: c.code,
name: names[c.code] || c.code,
places: c.places,
firstVisit: c.firstVisit,
}));
}
export interface StatsInput {
journeyId: number;
/** The route, already in the order it should be drawn. */
points: StatsInputPoint[];
entries: number;
photos: number;
places: number;
/** Dates from the trips, used when the entries carry none. */
tripDates: { start: string | null; end: string | null }[];
countryNames: Record<string, string>;
}
/**
* Fold everything into the figures a book puts on a page.
*
* Dates come from the entries when they have them and from the trips otherwise:
* an entry is a thing that happened on a day, so it is the better source, but a
* journey assembled from trips nobody has written entries for still has a
* length, and printing "0 DAYS" over a fortnight in Iceland would be worse than
* using the trip's own dates.
*/
export function computeJourneyStats(input: StatsInput): JourneyStats {
const points = thinRoute(input.points);
const dated = points.map(p => isoDay(p.date)).filter((d): d is string => !!d).sort();
const tripStarts = input.tripDates.map(t => isoDay(t.start)).filter((d): d is string => !!d).sort();
const tripEnds = input.tripDates.map(t => isoDay(t.end)).filter((d): d is string => !!d).sort();
const start = dated[0] ?? tripStarts[0] ?? null;
const end = dated[dated.length - 1] ?? tripEnds[tripEnds.length - 1] ?? null;
const geo = points.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng));
return {
journeyId: input.journeyId,
distance: Math.round(routeDistance(geo)),
days: daysBetween(start, end),
steps: input.entries,
photos: input.photos,
places: input.places,
furthest: Math.round(furthestFromStart(geo)),
countries: collectCountries(points, input.countryNames),
points: points.map((p): JourneyStatsPoint => ({
lat: p.lat,
lng: p.lng,
label: p.label,
date: p.date,
country: p.country,
})),
start,
end,
};
}
@@ -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 { JourneyBookService } from './journey-book.service';
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
@@ -36,6 +37,7 @@ import {
JourneyEntryCreateDto, JourneyEntryPhotoUploadDto, JourneyEntryUpdateDto, JourneyGalleryVideoDto,
JourneyLinkPhotoDto, JourneyPhotoUpdateDto, JourneyPreferencesDto, JourneyProviderPhotosDto,
JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto,
BookSaveDto,
} from './journey.dto';
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -127,6 +129,7 @@ export class JourneyController {
constructor(
private readonly journey: JourneyService,
private readonly storage: StorageService,
private readonly books: JourneyBookService,
private readonly captureBackfill: PhotoCaptureBackfillService,
) {}
@@ -520,6 +523,87 @@ export class JourneyController {
return { tracks };
}
/**
* The figures TREK Studio prints on a page — distance, days, steps, photos,
* countries and the route itself (#1973).
*
* Read-only and derived, like /tracks above it: nothing here is stored, it is
* what the trips added to this journey add up to. Studio freezes the answer
* into the book document when an element is placed, so this is called while
* designing and never while printing.
*/
@Get(':id/stats')
stats(@CurrentUser() user: User, @Param('id') id: string) {
const stats = this.journey.journeyStats(Number(id), user.id);
if (!stats) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return stats;
}
/*
* ── The Studio book (#1973) ──────────────────────────────────────────
*
* A book belongs to its journey and inherits its access exactly: every
* contributor may open and edit it. No second permission model over the same
* object — that is how two rules end up disagreeing about who may do what.
*/
@Get(':id/book')
getBook(@CurrentUser() user: User, @Param('id') id: string) {
const book = this.books.getBook(Number(id), user.id);
if (book === null && !this.books.canOpen(Number(id), user.id)) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
// A journey with no book yet is not an error: Studio opens, lays one out
// and saves it. Answering 404 would make "no book" and "no journey"
// indistinguishable to the client.
return { book };
}
@Put(':id/book')
@HttpCode(200)
saveBook(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: BookSaveDto,
@Headers('x-socket-id') socketId?: string,
) {
const result = this.books.saveBook(Number(id), user.id, {
title: body.title ?? '',
document: body.document,
baseVersion: body.baseVersion,
});
if (result === null) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
if ('conflict' in result) {
/*
* 409, with the current record in the body.
*
* Two people editing is the normal case here, not an exception — the
* client needs to be able to show what the other version is, and a bare
* status code would make that a second round trip at exactly the moment
* someone is worried about losing work.
*/
throw new HttpException(
{ error: 'Book was changed by someone else', current: result.conflict },
409,
);
}
this.books.broadcastSaved(Number(id), user.id, result.record, socketId);
return result.record;
}
@Delete(':id/book')
@HttpCode(204)
deleteBook(@CurrentUser() user: User, @Param('id') id: string) {
const removed = this.books.deleteBook(Number(id), user.id);
if (removed === null) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
}
@Post(':id/entries')
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) {
if (!body.entry_date) {
+4
View File
@@ -15,6 +15,7 @@ import {
journeyReorderEntriesRequestSchema,
journeyShareLinkRequestSchema,
journeyUpdateRequestSchema,
bookSaveRequestSchema,
} from '@trek/shared';
/**
@@ -49,3 +50,6 @@ export class JourneyLinkPhotoDto extends createZodDto(journeyLinkPhotoRequestSch
export class JourneyPhotoUpdateDto extends createZodDto(journeyPhotoUpdateRequestSchema) {}
export class JourneyEntryPhotoUploadDto extends createZodDto(journeyEntryPhotoUploadRequestSchema) {}
export class JourneyGalleryVideoDto extends createZodDto(journeyGalleryVideoRequestSchema) {}
/** A Studio book save — the whole document, plus the version it was made against. */
export class BookSaveDto extends createZodDto(bookSaveRequestSchema) {}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JourneyController } from './journey.controller';
import { JourneyPublicController } from './journey-public.controller';
import { JourneyService } from './journey.service';
import { JourneyBookService } from './journey-book.service';
import { AddonsModule } from '../addons/addons.module';
import { MemoriesModule } from '../memories/memories.module';
import { JourneyDomainModule } from './journey-domain.module';
@@ -34,6 +35,6 @@ import { journeyImageFileFilter, journeyUploadFilename } from './journey.control
StorageModule,
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
controllers: [JourneyController, JourneyPublicController],
providers: [JourneyService, JourneyMcp],
providers: [JourneyService, JourneyBookService, JourneyMcp],
})
export class JourneyModule {}
@@ -52,6 +52,7 @@ export class JourneyService {
// Entries
listEntries(id: number, userId: number) { return this.journey.listEntries(id, userId); }
journeyTracks(id: number, userId: number) { return this.journey.journeyTracks(id, userId); }
journeyStats(id: number, userId: number) { return this.journey.journeyStats(id, userId); }
// Entry create/update bodies are free-form in the legacy route (req.body: any);
// the cast keeps that boundary here so callers needn't pre-shape the payload.
createEntry(id: number, userId: number, data: Record<string, unknown>, sid?: string) { return this.journey.createEntry(id, userId, data as Parameters<typeof this.journey.createEntry>[2], sid); }
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { RealtimeGateway } from './realtime.gateway';
import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
import { JourneyDomainModule } from '../journey/journey-domain.module';
/**
* The transport, kept out of RealtimeModule on purpose.
@@ -17,7 +18,9 @@ import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
* TrekWsAdapter already registered.
*/
@Module({
imports: [EphemeralTokenModule],
// JourneyDomainModule for the book rooms: who may open a journey is asked
// of the same service the REST routes ask.
imports: [EphemeralTokenModule, JourneyDomainModule],
providers: [RealtimeGateway],
})
export class RealtimeGatewayModule {}
@@ -14,13 +14,20 @@ import { DatabaseService } from '../database/database.service';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
import { User } from '../../types';
import {
bookPeers,
broadcastToBook,
joinBook,
joinRoom,
leaveAllBooks,
leaveAllRooms,
leaveBook,
leaveRoom,
registerSocket,
socketIdOf,
userOf,
type TrekWebSocket,
} from './ws-state';
import { JourneyDomainService } from '../journey/journey-domain.service';
const HEARTBEAT_INTERVAL = 30_000;
@@ -52,6 +59,12 @@ export class RealtimeGateway
constructor(
private readonly db: DatabaseService,
private readonly tokens: EphemeralTokenService,
/*
* For the book rooms, and injected rather than reimplemented: who may open
* a journey is one question with one answer, and a second copy of it here
* is a second thing to keep in step with the REST routes.
*/
private readonly journeys: JourneyDomainService,
) {}
afterInit(server: WebSocketServer): void {
@@ -131,6 +144,9 @@ export class RealtimeGateway
handleDisconnect(socket: TrekWebSocket): void {
leaveAllRooms(socket);
// Tell the books this socket was in, or its pointer stays on everyone
// else's page forever.
for (const journeyId of leaveAllBooks(socket)) this.announcePeers(journeyId);
}
@SubscribeMessage('join')
@@ -149,6 +165,94 @@ export class RealtimeGateway
return { type: 'joined', tripId };
}
/*
* ── Studio books (#1973) ────────────────────────────────────────────────
*
* Presence and pointers for people editing the same photo book. Both are
* ephemeral by design: nothing here is written down, and a socket that goes
* away takes its pointer with it.
*/
@SubscribeMessage('book:join')
handleBookJoin(
@MessageBody() message: { journeyId?: number | string },
@ConnectedSocket() socket: TrekWebSocket,
): { type: string; journeyId?: number; message?: string } | undefined {
const user = userOf(socket);
if (!user || !message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
if (!Number.isFinite(journeyId) || !this.journeys.canAccessJourney(journeyId, user.id)) {
return { type: 'error', message: 'Access denied' };
}
joinBook(socket, journeyId);
this.announcePeers(journeyId);
return { type: 'book:joined', journeyId };
}
@SubscribeMessage('book:leave')
handleBookLeave(
@MessageBody() message: { journeyId?: number | string },
@ConnectedSocket() socket: TrekWebSocket,
): { type: string; journeyId?: number } | undefined {
if (!message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
leaveBook(socket, journeyId);
this.announcePeers(journeyId);
return { type: 'book:left', journeyId };
}
/**
* Forward a pointer to the others in the book.
*
* Nothing is checked against the database on this path, and that is the
* point: it runs ten times a second per editor, and the socket already
* proved it may be here when it joined. What it cannot do is send to a book
* it never joined — the room is the authorisation, so a socket that is not
* in it broadcasts to nobody.
*
* The coordinates are millimetres on the spread, not pixels on a screen. Two
* people are at different zoom levels on different monitors; a pixel means
* nothing to the other one.
*/
@SubscribeMessage('book:cursor')
handleBookCursor(
@MessageBody() message: {
journeyId?: number | string;
spreadIndex?: number;
x?: number | null;
y?: number | null;
},
@ConnectedSocket() socket: TrekWebSocket,
): undefined {
const user = userOf(socket);
const sid = socketIdOf(socket);
if (!user || sid == null || !message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
if (!bookPeers(journeyId).some((p) => p.socketId === sid)) return undefined;
broadcastToBook(
journeyId,
{
type: 'journey:book:cursor',
socketId: sid,
userId: user.id,
spreadIndex: Math.max(0, Math.trunc(Number(message.spreadIndex) || 0)),
x: finiteOrNull(message.x),
y: finiteOrNull(message.y),
},
sid,
);
return undefined;
}
/** The whole list, to everyone in the book including whoever just changed it. */
private announcePeers(journeyId: number): void {
broadcastToBook(journeyId, { type: 'journey:book:peers', peers: bookPeers(journeyId) });
}
@SubscribeMessage('leave')
handleLeave(
@MessageBody() message: { tripId?: number | string },
@@ -160,3 +264,16 @@ export class RealtimeGateway
return { type: 'left', tripId };
}
}
/**
* A coordinate, or null for a pointer that has left the page.
*
* The null check is explicit because `Number(null)` is 0, not NaN — leaving the
* page would have parked everyone's arrow in the top-left corner of the spread
* rather than taking it away.
*/
function finiteOrNull(value: number | null | undefined): number | null {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? Math.round(n * 100) / 100 : null;
}
+65 -14
View File
@@ -15,13 +15,40 @@ import { logError } from '../audit/audit-log.logger';
// flood the process with garbage.
const WS_MSG_LIMIT = 30; // max messages
const WS_MSG_WINDOW = 10_000; // per 10 seconds
const rates = new WeakMap<TrekWebSocket, { count: number; windowStart: number }>();
function withinRate(socket: TrekWebSocket): boolean {
let rate = rates.get(socket);
/**
* A second, looser ceiling that every frame counts against.
*
* Studio's pointers (#1973) move about ten times a second per editor, which is
* three times what the limit above allows on its own — and that limit cannot
* simply be raised, because it is what stops a client flooding the process with
* frames nobody asked for.
*
* So the frames are counted twice. Everything counts against this ceiling,
* before parsing, exactly as before; the tighter limit then applies to
* everything except pointers. A client can send a lot of pointers and no more
* of anything else than it ever could, and a client sending garbage is still
* cut off — just at 200 frames per ten seconds rather than 30, which is a
* trade worth naming: those frames are small, and the alternative is either no
* live pointers or a limit high enough to be no limit.
*/
const WS_BURST_LIMIT = 200;
/** The one message type exempt from the tighter limit. */
const HIGH_RATE_TYPE = 'book:cursor';
const rates = new WeakMap<TrekWebSocket, { count: number; windowStart: number }>();
const bursts = new WeakMap<TrekWebSocket, { count: number; windowStart: number }>();
function within(
store: WeakMap<TrekWebSocket, { count: number; windowStart: number }>,
socket: TrekWebSocket,
limit: number,
): boolean {
let rate = store.get(socket);
if (!rate) {
rate = { count: 0, windowStart: Date.now() };
rates.set(socket, rate);
store.set(socket, rate);
}
const now = Date.now();
if (now - rate.windowStart > WS_MSG_WINDOW) {
@@ -30,7 +57,15 @@ function withinRate(socket: TrekWebSocket): boolean {
return true;
}
rate.count++;
return rate.count <= WS_MSG_LIMIT;
return rate.count <= limit;
}
function withinRate(socket: TrekWebSocket): boolean {
return within(rates, socket, WS_MSG_LIMIT);
}
function withinBurst(socket: TrekWebSocket): boolean {
return within(bursts, socket, WS_BURST_LIMIT);
}
/**
@@ -132,20 +167,36 @@ export class TrekWsAdapter extends WsAdapter {
transform: (data: unknown) => Observable<unknown>,
): void {
socket.on('message', (buffer: Buffer) => {
if (!withinRate(socket)) {
const tooLoud = () => {
if (socket.readyState === 1) {
socket.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
}
return;
};
// The outer ceiling, before parsing: this is what stops a client flooding
// the process, and it counts every frame no matter what is in it.
if (!withinBurst(socket)) return tooLoud();
let message: { type?: unknown } | null = null;
try {
const parsed = JSON.parse(buffer.toString());
if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') message = parsed;
} catch {
// Malformed JSON. Still counted below, exactly as before.
}
let message: { type?: unknown };
try {
message = JSON.parse(buffer.toString());
} catch {
return; // Malformed JSON, ignored exactly as before.
}
if (!message || typeof message !== 'object' || typeof message.type !== 'string') return;
/*
* The tighter limit, on everything a pointer is not — malformed JSON and
* unknown types included, which is the point of counting them here rather
* than only counting what reaches a handler.
*
* Parsing first costs a JSON.parse on a small frame that the ceiling
* above has already let through. That is the price of telling a pointer
* from everything else, and it buys the tight limit staying exactly as
* tight as it was for every other frame.
*/
if (message?.type !== HIGH_RATE_TYPE && !withinRate(socket)) return tooLoud();
if (!message) return;
const handler = handlers.find((h) => h.message === message.type);
if (!handler) return; // An unknown type is ignored, not an error frame.
+92
View File
@@ -23,6 +23,18 @@ export interface TrekWebSocket extends WebSocket {
}
const rooms = new Map<number, Set<TrekWebSocket>>();
/**
* Who has a Studio book open, per journey.
*
* Separate from the trip rooms above, and separate from "is a contributor
* online": a book carries pointers and a presence list, and both are only of
* interest to the people actually looking at it. Fanning them out to every
* contributor's sockets would send a pointer moving at ten frames a second to
* someone reading the journey on their phone.
*/
const bookRooms = new Map<number, Set<TrekWebSocket>>();
const socketBooks = new WeakMap<TrekWebSocket, Set<number>>();
const socketRooms = new WeakMap<TrekWebSocket, Set<number>>();
const socketUser = new WeakMap<TrekWebSocket, User>();
const socketId = new WeakMap<TrekWebSocket, number>();
@@ -81,6 +93,86 @@ export function leaveAllRooms(ws: TrekWebSocket): void {
for (const tripId of mine) leaveRoom(ws, tripId);
}
// ── Studio books ──────────────────────────────────────────────────────────
export interface BookPeer {
socketId: number;
userId: number;
username: string;
avatar?: string | null;
}
export function joinBook(ws: TrekWebSocket, journeyId: number): void {
if (!bookRooms.has(journeyId)) bookRooms.set(journeyId, new Set());
bookRooms.get(journeyId)!.add(ws);
if (!socketBooks.has(ws)) socketBooks.set(ws, new Set());
socketBooks.get(ws)!.add(journeyId);
}
export function leaveBook(ws: TrekWebSocket, journeyId: number): void {
const room = bookRooms.get(journeyId);
if (room) {
room.delete(ws);
if (room.size === 0) bookRooms.delete(journeyId);
}
socketBooks.get(ws)?.delete(journeyId);
}
/** Every book this socket had open — called when the connection goes. */
export function leaveAllBooks(ws: TrekWebSocket): number[] {
const mine = socketBooks.get(ws);
if (!mine) return [];
const left = [...mine];
for (const journeyId of left) leaveBook(ws, journeyId);
return left;
}
/**
* Who is in a book, by socket rather than by user.
*
* One person with two tabs open is two entries on purpose: they have two
* pointers, and a list keyed by user could not say which one moved.
*/
export function bookPeers(journeyId: number): BookPeer[] {
const room = bookRooms.get(journeyId);
if (!room) return [];
const peers: BookPeer[] = [];
for (const ws of room) {
if (ws.readyState !== 1) continue;
const user = socketUser.get(ws);
const sid = socketId.get(ws);
if (!user || sid == null) continue;
peers.push({ socketId: sid, userId: user.id, username: user.username, avatar: user.avatar ?? null });
}
return peers;
}
/**
* Send to everyone looking at a book.
*
* No plugin event sink here, unlike the trip broadcast: a pointer is not
* something that happened to the trip, and announcing ten of them a second to
* every subscribed plugin would be a firehose of nothing.
*/
export function broadcastToBook(
journeyId: number,
payload: Record<string, unknown>,
excludeSid?: number,
): void {
const room = bookRooms.get(journeyId);
if (!room || room.size === 0) return;
for (const ws of room) {
if (ws.readyState !== 1) continue;
if (excludeSid != null && socketId.get(ws) === excludeSid) continue;
ws.send(JSON.stringify({ journeyId, ...payload }));
}
}
/** The socket's own id, so a handler can name the pointer it is forwarding. */
export function socketIdOf(ws: TrekWebSocket): number | undefined {
return socketId.get(ws);
}
/**
* Broadcast an event to all sockets in a trip room, optionally excluding a
* socket.