mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Merge remote-tracking branch 'origin/feat/storage-driver' into feat/storage-driver
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user