Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ Copy the `.env.example` file to `.env`.

Adjust the variable values based to your needs.


## Running the server

```bash
Expand All @@ -45,6 +44,52 @@ $ npm run start:dev
$ npm run start:prod
```

## TripNotify live updates

Owners can list and manage all their own trips, including scheduled and expired
ones. Guest access still follows each share's dates, Premium status, and access
after expiry setting.

Shared history selects sessions by their recorded sample timestamps, including
delayed uploads created after the share ends when historical access is retained.
Samples are limited to `start <= timestamp < end`. Displayed session dates include
the selected samples and are clamped to the sharing window. Partial-session
measurements are recalculated from those samples without changing the stored
log. Missing measurements are omitted from the response, and the website shows
"–" instead of a misleading zero. Trip totals are omitted when any contributing
session lacks the necessary measurements. Energy uses the difference between
recorded counters inside the window; values between samples or across the window
boundaries are not estimated.

The bundled website lives in `public/tripnotify/index.html` and is served at
`/frontend/tripnotify/?code=SHARE_CODE`. It sends the required socket credentials
and displays live connection errors separately from trip-loading errors.

Socket.IO clients must supply the trip's share code when connecting:

```ts
const socket = io(serverUrl, { auth: { code: shareCode } });
socket.on(`route-update:${shareCode}`, ({ logId, data }) => {
// Handle this trip's update.
});
socket.on('connect_error', (error) => {
// Unauthorized shares receive the generic message "Unauthorized".
});
```

Each connection receives only its authorized share's updates. Reconnect with a new
connection to switch shares. Live delivery requires an active trip and active
owner Premium; permissions are checked again before each update. Each sample must
also have a valid timestamp within `start <= timestamp < end`; uploading old data
during an active share does not authorize sharing it. When location
sharing is disabled, updates include only timestamp and allowed battery/charging
telemetry, including temperature readings. Historical access after a trip ends
does not extend live delivery.

**Deployment:** update consuming clients to send `auth.code` before deploying this
server change. Clients that omit it will be rejected. There is no unrestricted
compatibility fallback. See the [implementation plan](docs/plans/tripnotify-socket-privacy.md).

## Test

```bash
Expand Down
88 changes: 88 additions & 0 deletions docs/plans/tripnotify-socket-privacy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# TripNotify sharing fixes

## Summary

Prevent live trip updates from reaching unauthorized clients, restore the owner's
complete trip list, and include history from sessions crossing sharing boundaries.
Enforce sharing dates, active Premium, and location preferences.

## Implementation

- List all trips belonging to the authenticated owner, independent of guest
sharing dates and retained-access settings. Keep guest authorization unchanged.
- Select sessions by their sample timestamps, including delayed uploads whose
server-created session dates do not overlap the share. Safely convert stored
dates and timestamp strings; omit logs with no valid samples in the window.
- Build a response-only view: expand recorded session bounds to include selected
samples, clamp dates to the share, and mark a running
session's shared portion finished once the share ends. Leave stored logs intact.
- Recalculate partial-session SOC/counter endpoints, energy, averages, and distance
from sorted in-window samples. Preserve valid zeros, omit insufficient or reset
counter measurements, and do not interpolate across the boundaries. Preserve
stored measurements for fully contained sessions. Omit aggregate totals if any
contributing session lacks the required data; display unavailable values as
"–" on the website.
- Classify unknown sessions with zero battery power and a finite average speed
as driving; preserve zero measurements and explicit session types.
- Share the battery-field allowlist between HTTP history and live updates so
mixed telemetry cannot expose location when location sharing is disabled.
- Require `auth: { code: string }` in the Socket.IO handshake. Authorize through
`TripNotifyService.findAccessibleByCode()` before accepting the connection.
Reject missing, malformed, unknown, or inaccessible codes with a generic
`connect_error` whose message is `Unauthorized`.
- Assign each socket to one server-controlled share room. Expose no client room
joining messages. Replace global broadcasts with delivery to that room.
- Preserve `route-update:${code}` and the `{ logId, data }` payload structure.
- Adapt the bundled `public/tripnotify/index.html` website to send `auth.code` to
the same backend as its HTTP requests. Display generic live connection errors
separately from trip-loading errors; clear only the live error on reconnection.
- Before every broadcast, revalidate the share and Premium. Suppress updates on
authorization failure, deletion, or database errors. Live access requires
`startDate <= now < endDate`, even when historical access is retained. Each
sample also needs a valid timestamp in `startDate <= timestamp < endDate`.
- For location-disabled shares, copy only timestamp and explicitly allowed
battery/charging telemetry, including temperature readings. Exclude coordinates,
altitude, accuracy, speed, odometer, and unknown fields. Never modify the source
sync object or another share's payload.
- Await all per-share delivery checks in the sync handler. Record failures
without share codes, account identifiers, location data, or raw database errors.

## Verification

Use isolated MongoDB, real Premium queries, and real Socket.IO clients. Add
`socket.io-client` as a development dependency.

- Verify all owner trip states are listed while account isolation and
authentication remain enforced.
- Cover both overlap boundaries, running and spanning sessions, exact endpoint
timestamps, invalid/unsorted samples, empty windows, zero readings, counter
resets, partial totals, and stored-log immutability.
- Ingest delayed samples through the real sync service after share expiry and
verify retained history, bounded dates, and running/finished logs. Cover stored
dates, timestamp strings, malformed histories, and account isolation.
- Check unknown driving sessions with zero battery power and explicit zero-power
charging sessions without losing zero measurements.
- Check website formatting for zero and unavailable totals, including charge-stop
summaries, alongside socket handshake and error-handling smoke checks.
- Reject unauthorized handshakes; verify exact start/end boundaries and both
historical-access settings.
- Verify room isolation with catch-all listeners and multiple clients per share.
- Verify the existing event and payload structure for authorized clients.
- Verify sample timestamps independently of upload time, including exact start
and end boundaries, delayed samples, and missing/invalid timestamps.
- Verify mixed GPS/battery payloads are filtered without modifying the input.
- Verify changes to location preferences, Premium expiry, deletion, and trip
expiry take effect for already-connected clients.
- Verify database failures suppress delivery and do not expose error details.
- Run guest HTTP regressions, socket regressions, and the build. Report the
existing account-guard test failure separately if it persists.

## Compatibility and rollout

Share codes remain bearer credentials. Code strength and distribution are outside
this change. Update consuming clients to send `auth.code` on every connection and
reconnection before deploying enforcement. Clients without it will lose live
updates; retain no unrestricted fallback.

Historical access after expiry may authorize a connection, but produces no live
updates. No application deployment or client repository changes are included here.
62 changes: 62 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"eslint-plugin-prettier": "^5.4.0",
"jest": "^29.7.0",
"prettier": "^3.5.3",
"socket.io-client": "^4.8.4",
"source-map-support": "^0.5.21",
"supertest": "^7.1.1",
"ts-jest": "^29.3.4",
Expand All @@ -85,6 +86,9 @@
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
Expand Down
55 changes: 42 additions & 13 deletions public/tripnotify/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,22 @@ <h2>Trip information</h2>
const stopsEl = document.getElementById("chargingStops");
const errorEl = document.getElementById("error");
const infoEl = document.getElementById("info");
const errors = { trip: '', socket: '' };

function showError(message) {
errorEl.style.display = 'block';
errorEl.textContent = message;
function renderErrors() {
const messages = Object.values(errors).filter(Boolean);
errorEl.style.display = messages.length ? 'block' : 'none';
errorEl.textContent = messages.join(' ');
}

function hideError() {
errorEl.style.display = 'none';
function showError(message, source = 'trip') {
errors[source] = message;
renderErrors();
}

function hideError(source = 'trip') {
errors[source] = '';
renderErrors();
}

const map = L.map('map').setView([51.505, -0.09], 13);
Expand Down Expand Up @@ -149,8 +157,21 @@ <h2>Trip information</h2>
showError("Trip could not be loaded");
});

const socket = io({
query: { code }
const socket = io(baseUrl || window.location.origin, {
auth: { code }
});

socket.on('connect', () => {
hideError('socket');
});

socket.on('connect_error', (error) => {
showError(
error.message === 'Unauthorized'
? 'Live updates unavailable: this share is not authorized'
: 'Live updates unavailable: could not connect to the server',
'socket'
);
});

socket.on(`route-update:${code}`, (payload) => {
Expand All @@ -174,18 +195,26 @@ <h2>Trip information</h2>
endEl.textContent = info.end ? new Date(info.end).toLocaleString() : "–";
}

function formatMetric(value, unit, decimals) {
if (!Number.isFinite(value)) {
return "–";
}
return `${decimals === undefined ? value : value.toFixed(decimals)} ${unit}`;
}

function updateSidebarMetadataAndChargingStops(info) {
consumedEl.textContent = info.metadata.consumedEnergy ? info.metadata.consumedEnergy + ' kWh' : "–";
chargedEl.textContent = info.metadata.rechargedEnergy ? info.metadata.rechargedEnergy + ' kWh' : "–";
avgSpeedEl.textContent = info.metadata.avgDriveSpeed ? info.metadata.avgDriveSpeed + " km/h" : "–";
avgChargeEl.textContent = info.metadata.avgChargeSpeed ? info.metadata.avgChargeSpeed + " kW" : "–";
consumedEl.textContent = formatMetric(info.metadata.consumedEnergy, 'kWh');
chargedEl.textContent = formatMetric(info.metadata.rechargedEnergy, 'kWh');
avgSpeedEl.textContent = formatMetric(info.metadata.avgDriveSpeed, 'km/h');
avgChargeEl.textContent = formatMetric(info.metadata.avgChargeSpeed, 'kW');

stopsEl.innerHTML = '';
(info.logs || []).forEach(((log) => {
if (log.type === 'charge') {
const li = document.createElement("li");

const text = `${new Date(log.startDate).toLocaleDateString()}<br> ${new Date(log.startDate).toLocaleTimeString()} ${log.endDate ? '- ' + new Date(log.endDate).toLocaleTimeString() : ' (still active)'} <br> (charged: ${(log.rechargedKWh || 0).toFixed(2)} kWh, ⌀ ${Math.abs(log.averageKW || 0).toFixed(2)} kW)`;
const averageKW = Number.isFinite(log.averageKW) ? Math.abs(log.averageKW) : undefined;
const text = `${new Date(log.startDate).toLocaleDateString()}<br> ${new Date(log.startDate).toLocaleTimeString()} ${log.endDate ? '- ' + new Date(log.endDate).toLocaleTimeString() : ' (still active)'} <br> (charged: ${formatMetric(log.rechargedKWh, 'kWh', 2)}, ⌀ ${formatMetric(averageKW, 'kW', 2)})`;

li.innerHTML = text;
stopsEl.appendChild(li);
Expand Down Expand Up @@ -220,4 +249,4 @@ <h2>Trip information</h2>
</script>
</body>

</html>
</html>
Loading
Loading