Compare commits

...

24 Commits

Author SHA1 Message Date
D. Berge
cd23a78592 Merge branch '190-refactor-map' into 'devel'
Resolve "Refactor map"

Closes #190, #322, #323, #324, #325, #326, and #321

See merge request wgp/dougal/software!25
2025-08-11 13:01:00 +00:00
D. Berge
e368183bf0 Show release notes for previous versions too 2025-08-11 14:59:22 +02:00
D. Berge
02477b071b Compress across the board.
It's still subject to the compression module's filters, but now
we try to compress every response in principle.
2025-08-11 13:57:11 +02:00
D. Berge
6651868ea7 Enable compression for vessel track responses 2025-08-11 13:40:53 +02:00
D. Berge
c0b52a8245 Be more aggressive about what gets compressed 2025-08-11 12:42:48 +02:00
D. Berge
90ce6f063e Remove dead code 2025-08-11 02:31:43 +02:00
D. Berge
b2fa0c3d40 Flatten vesselTrackConfig for better reactivity 2025-08-11 02:31:12 +02:00
D. Berge
83ecaad4fa Change vessel colour 2025-08-11 01:57:40 +02:00
D. Berge
1c5fd2e34d Calculate properly first / last timestamps of vessel tracks 2025-08-11 01:56:46 +02:00
D. Berge
aabcc74891 Add compression to some endpoints.
Consideration will be given to adding (conditional) compression
to all endpoints.
2025-08-11 01:53:50 +02:00
D. Berge
2a7b51b995 Squash another cookie 2025-08-11 01:52:04 +02:00
D. Berge
5d19ca7ca7 Add authentication to vessel track request 2025-08-10 22:03:25 +02:00
D. Berge
910195fc0f Comment out "Map settings" control on map.
Not sure it will actually be used, after all.
2025-08-10 21:53:55 +02:00
D. Berge
6e5570aa7c Add missing require 2025-08-10 21:53:04 +02:00
D. Berge
595c20f504 Add vessel position to map.
Updates via websocket using the `realtime` channel notification
message.
2025-08-10 21:52:02 +02:00
D. Berge
40d0038d80 Add vessel track layer to map.
Track length may be changed by clicking on the appropriate icon.
2025-08-10 21:47:43 +02:00
D. Berge
acdf118a67 Add new /vessel/track endpoints.
This is a variation on /navdata but returns data more suitable
for plotting vessel tracks on the map.
2025-08-10 21:39:35 +02:00
D. Berge
b9e0975d3d Add clone routine to project DB lib (WIP).
This relates to #333.
2025-08-10 21:37:12 +02:00
D. Berge
39d9c9d748 Fix GeoJSON returned by /navdata endpoint 2025-08-10 21:36:37 +02:00
D. Berge
b8b25dcd62 Update IP getter script to return LAN address.
get-ip.sh internet: returns the first IP address found that has
internet access.

get-ip.sh local (or no argument): returns the list of non-loopback
IPs minus the one that has internet access.

This means that update-dns.sh now sends the first IP address that
does *not* have internet access.
2025-08-09 22:27:23 +02:00
D. Berge
db97382758 Add scripts to automatically update the LAN DNS records.
./sbin/update-dns.sh may be called at regular intervals (one hour
or so) via crontab.

It will automatically detect:
- its local host name (*.lan.dougal.aaltronav.eu); and
- which IP has internet access, if any.

Armed with that information and with the dynamic DNS API password
stored in DYNDNS_PASSWD in ~/.dougalrc, it will update the relevant
DNS record.

For this to work, the first `lan.dougal` hostname in the Nginx
configuration must be the one that is set up for dynamic update.
Other `lan.dougal` hostnames should be CNAME records pointing to
the first one.
2025-08-09 18:37:15 +02:00
D. Berge
ae8e5d4ef6 Do not use cookies for backend authentication 2025-08-09 12:43:17 +02:00
D. Berge
2c1a24e4a5 Do not store JWT in document.cookie 2025-08-09 12:14:17 +02:00
D. Berge
0b83187372 Provide authorisation details to Deck.gl layers.
Those layers that call API endpoints directly no longer need to
rely on cookies as they use the JWT token directly via the
`Authorization` header.
2025-08-09 12:12:24 +02:00
27 changed files with 407825 additions and 158 deletions

View File

@@ -9,10 +9,12 @@
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/mesh-layers": "^9.1.14",
"@dougal/binary": "file:../../../modules/@dougal/binary",
"@dougal/concurrency": "file:../../../modules/@dougal/concurrency",
"@dougal/organisations": "file:../../../modules/@dougal/organisations",
"@dougal/user": "file:../../../modules/@dougal/user",
"@loaders.gl/obj": "^4.3.4",
"@mdi/font": "^7.2.96",
"buffer": "^6.0.3",
"core-js": "^3.6.5",

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,16 @@
</v-card-title>
<v-card-text>
<pre>{{ versionHistory }}</pre>
<v-carousel v-model="releaseShown"
:continuous="false"
:cycle="false"
:show-arrows="true"
:hide-delimiters="true"
>
<v-carousel-item v-for="release in releaseHistory">
<pre>{{release}}</pre>
</v-carousel-item>
</v-carousel>
</v-card-text>
@@ -127,6 +136,8 @@ export default {
clientVersion: process.env.DOUGAL_FRONTEND_VERSION ?? "(unknown)",
serverVersion: null,
versionHistory: null,
releaseHistory: [],
releaseShown: null,
page: "support"
};
},
@@ -138,7 +149,8 @@ export default {
this.serverVersion = version?.tag ?? "(unknown)";
}
if (!this.versionHistory) {
const history = await this.api(['/version/history?count=1', {}, null, {silent:true}]);
const history = await this.api(['/version/history?count=3', {}, null, {silent:true}]);
this.releaseHistory = history;
this.versionHistory = history?.[this.serverVersion.replace(/-.*$/, "")] ?? null;
}
},

View File

@@ -25,20 +25,22 @@ async function login ({ commit, dispatch }, loginRequest) {
async function logout ({ commit, dispatch }) {
commit('setToken', null);
commit('setUser', null);
commit('setCookie', {value: null});
await dispatch('api', ["/logout"]);
commit('setPreferences', {});
}
function setCookie(context, {name, value, expiry, path}) {
if (!name) name = "JWT";
if (!path) path = "/";
if (!value) value = "";
if (expiry) {
document.cookie = `${name}=${value}; expiry=${(new Date(expiry)).toUTCString()}; path=${path}`;
if (name) {
if (expiry) {
document.cookie = `${name}=${value}; expiry=${(new Date(expiry)).toUTCString()}; path=${path}`;
} else {
document.cookie = `${name}=${value}; path=${path}`;
}
} else {
document.cookie = `${name}=${value}; path=${path}`;
console.warn(`seCookie: You must supply a name`);
}
}
@@ -60,17 +62,6 @@ function setCredentials({ state, commit, getters, dispatch, rootState }, { force
commit('setToken', tokenValue);
commit('setUser', decoded ? new User(decoded, rootState.api.api) : null);
if (tokenValue && decoded) {
if (decoded?.exp) {
dispatch('setCookie', {value: tokenValue, expiry: decoded.exp*1000});
} else {
dispatch('setCookie', {value: tokenValue});
}
} else {
// Clear the cookie
dispatch('setCookie', {value: "", expiry: 0});
}
console.log('Credentials refreshed at', new Date().toISOString());
} else {
console.log('JWT unchanged, skipping update');

View File

@@ -7,12 +7,6 @@ function jwt (state) {
return state.token;
}
function cookie (state) {
if (state.token) {
return "JWT="+token;
}
}
function preferences (state) {
return state.preferences;
}

View File

@@ -31,7 +31,47 @@
<span>Vessel track</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="navp" v-model="layerSelection"/></label>
<!--
<label title="Show lines" disabled><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="navl" v-model="layerSelection"/></label>
-->
<div>
<v-menu bottom offset-y class="pb-1">
<template v-slot:activator="{ on, attrs }">
<v-icon style="margin-right: 3px;" small v-bind="attrs" v-on="on" :title="`Show lines.\nCurrently selected period: ${vesselTrackPeriodSettings[vesselTrackPeriod].title}. Click to change`">mdi-vector-line</v-icon>
</template>
<v-list nav dense>
<v-list-item @click="vesselTrackPeriod = 'hour'">
<v-list-item-content>
<v-list-item-title>Last hour</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="vesselTrackPeriod = 'hour6'">
<v-list-item-content>
<v-list-item-title>Last 6 hours</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="vesselTrackPeriod = 'hour12'">
<v-list-item-content>
<v-list-item-title>Last 12 hours</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="vesselTrackPeriod = 'day'">
<v-list-item-content>
<v-list-item-title>Last 24 hours</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="vesselTrackPeriod = 'week'">
<v-list-item-content>
<v-list-item-title>Last week</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<input type="checkbox" value="navl" v-model="layerSelection"/>
</div>
<label><!-- No heatmap available --></label>
<span>Sail lines</span>
@@ -359,6 +399,7 @@
</v-select>
END QC data -->
<!--
<hr class="my-2"/>
<div title="Not yet implemented">
@@ -371,6 +412,7 @@
Map settings
</v-btn>
</div>
-->
</div>
</div>
@@ -620,6 +662,67 @@ export default {
maxPitch: 89
},
vesselPosition: null,
vesselTrackLastRefresh: 0,
vesselTrackRefreshInterval: 12, // seconds
vesselTrackIntervalID: null,
vesselTrackPeriod: "hour",
vesselTrackPeriodSettings: {
hour: {
title: "1 hour",
offset: 3600 * 1000,
decimation: 1,
refreshInterval: 18,
},
hour6: {
title: "6 hours",
offset: 6 * 3600 * 1000,
decimation: 1,
refreshInterval: 18,
},
hour12: {
title: "12 hours",
offset: 12 * 3600 * 1000,
decimation: 1,
refreshInterval: 18,
},
day: {
title: "24 hours",
offset: 24 * 3600 * 1000,
decimation: 12,
refreshInterval: 18,
},
week: {
title: "7 days",
offset: 7 * 24 * 3600 * 1000,
decimation: 60,
refreshInterval: 60,
},
week2: {
title: "14 days",
offset: 14 * 24 * 3600 * 1000,
decimation: 60,
refreshInterval: 90,
},
month: {
title: "30 days",
offset: 30 * 24 * 3600 * 1000,
decimation: 90,
refreshInterval: 120,
},
quarter: {
title: "90 days",
offset: 90 * 24 * 3600 * 1000,
decimation: 180,
refreshInterval: 300,
},
year: {
title: "1 year",
offset: 365 * 24 * 3600 * 1000,
decimation: 1200,
refreshInterval: 1800,
},
},
heatmapValue: "total_error",
isFullscreen: false,
crosshairsPositions: [],
@@ -761,6 +864,14 @@ export default {
deep: true
},
vesselTrackPeriod () {
this.updateVesselIntervalTimer();
},
vesselTrackLastRefresh () {
this.render();
},
lines () {
// Refresh map on change of preplot data
this.render();
@@ -1062,6 +1173,12 @@ export default {
return [[λ0 - , φ0 - ], [λ1 + , φ1 + ]];
},
// Returns the current second, as an integer.
// Used for triggering Deck.gl URL refreshes
currentSecond () {
return Math.floor(Date.now()/1000);
},
async getSequenceData (sequenceNumbers, types = [2, 3]) {
//const types = [2, 3]; // Bundle types: 2 → raw/gun data; 3 final data. See bundles.js
@@ -1407,6 +1524,19 @@ export default {
return arr.buffer;
},
updateVesselIntervalTimer (refreshInterval) {
this.vesselTrackRefreshInterval = refreshInterval ??
this.vesselTrackPeriodSettings[this.vesselTrackPeriod]?.refreshInterval ?? 0;
this.vesselTrackIntervalID = clearInterval(this.vesselTrackIntervalID);
if (this.vesselTrackRefreshInterval) {
this.vesselTrackLastRefresh = this.currentSecond();
this.vesselTrackIntervalID = setInterval( () => {
this.vesselTrackLastRefresh = this.currentSecond();
}, this.vesselTrackRefreshInterval * 1000);
}
},
async handleSequences (context, {payload}) {
if (payload.pid != this.$route.params.project) {
console.warn(`${this.$route.params.project} ignoring notification for ${payload.pid}`);
@@ -1438,18 +1568,46 @@ export default {
}
},
handleVesselPosition (context, {payload}) {
if (payload.new?.geometry?.coordinates) {
const now = Date.now();
const lastRefresh = this.vesselPosition?._lastRefresh;
// Limits refreshes to once every five seconds max
if (lastRefresh && (now-lastRefresh) < 5000) return;
this.vesselPosition = {
...payload.new.meta,
tstamp: payload.new.tstamp,
_lastRefresh: now
};
if (this.vesselPosition.lineStatus == "offline") {
this.vesselPosition.x = this.vesselPosition.longitude ?? payload.new.geometry.coordinates[0];
this.vesselPosition.y = this.vesselPosition.latitude ?? payload.new.geometry.coordinates[1];
} else {
this.vesselPosition.x = this.vesselPosition.longitudeMaster
?? payload.new.geometry.coordinates[0];
this.vesselPosition.y = this.vesselPosition.latitudeMaster
?? payload.new.geometry.coordinates[1];
}
this.render();
}
},
registerNotificationHandlers (action = "registerHandler") {
["raw_lines", "raw_shots", "final_lines", "final_shots"].forEach( table => {
this.$store.dispatch(action, {
table,
handler: (context, message) => {
this.handleSequences(context, message);
}
handler: this.handleSequences
})
});
this.$store.dispatch(action, {
table: 'realtime',
handler: this.handleVesselPosition
});
},
unregisterNotificationHandlers () {
@@ -1473,6 +1631,8 @@ export default {
console.log("TODO: Should switch to legacy map view");
}
this.updateVesselIntervalTimer();
this.layersAvailable.osm = this.osmLayer;
this.layersAvailable.sea = this.openSeaMapLayer;
@@ -1572,6 +1732,7 @@ export default {
beforeDestroy () {
this.unregisterNotificationHandlers();
this.vesselTrackIntervalID = this.clearInterval(this.vesselTrackIntervalID);
}
}

View File

@@ -5,8 +5,9 @@
import { Deck, WebMercatorViewport, FlyToInterpolator, CompositeLayer } from '@deck.gl/core';
import { GeoJsonLayer, LineLayer, PathLayer, BitmapLayer, ScatterplotLayer, ColumnLayer, IconLayer } from '@deck.gl/layers';
import {HeatmapLayer} from '@deck.gl/aggregation-layers';
import { TileLayer, MVTLayer } from '@deck.gl/geo-layers';
import { TileLayer, MVTLayer, TripsLayer } from '@deck.gl/geo-layers';
import { SimpleMeshLayer } from '@deck.gl/mesh-layers';
import { OBJLoader } from '@loaders.gl/obj';
//import { json } from 'd3-fetch';
import * as d3a from 'd3-array';
@@ -18,7 +19,6 @@ import DougalBinaryLoader from '@/lib/deck.gl/DougalBinaryLoader';
import { colors } from 'vuetify/lib'
function hexToArray (hex, defaultValue = [ 0xc0, 0xc0, 0xc0, 0xff ]) {
if (typeof hex != "string" || hex.length < 6) {
@@ -121,6 +121,21 @@ export default {
};
},
loadOptions (options = {}) {
return {
loadOptions: {
fetch: {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.$store.getters.jwt}`,
}
},
...options
},
};
},
osmLayer (options = {}) {
return new TileLayer({
@@ -241,45 +256,99 @@ export default {
},
vesselTrackPointsLayer (options = {}) {
return new ScatterplotLayer({
if (!this.vesselPosition) return;
return new SimpleMeshLayer({
id: 'navp',
data: `/api/navdata?limit=10000`,
getPosition: (d) => ([d.longitude, d.latitude]),
getRadius: d => (d.speed),
radiusScale: 1,
lineWidthMinPixels: 2,
getFillColor: d => d.guns
? d.lineStatus == "online"
? [0xaa, 0x00, 0xff] // Online
: [0xd5, 0x00, 0xf9] // Soft start or guns otherwise active
: [0xea, 0x80, 0xfc], // Offline, guns inactive
getLineColor: [127, 65, 90],
getColor: [ 255, 0, 0 ],
getPointRadius: 12,
radiusUnits: "pixels",
pointRadiusMinPixels: 4,
stroked: false,
filled: true,
data: [ this.vesselPosition ],
//getColor: [ 255, 48, 0 ],
getColor: [ 174, 1, 174 ],
getOrientation: d => [0, (270 - (d.heading ?? d.cmg ?? d.bearing ?? d.lineBearing ?? 0)) % 360 , 0],
getPosition: d => [ d.x, d.y ],
mesh: `/assets/boat0.obj`,
sizeScale: 0.1,
loaders: [OBJLoader],
pickable: true,
...options
})
});
},
vesselTrackLinesLayer (options = {}) {
return new LineLayer({ // TODO Change to TrackLayer
const cfg = this.vesselTrackPeriodSettings[this.vesselTrackPeriod];
let ts1 = new Date(this.vesselTrackLastRefresh*1000);
let ts0 = new Date(ts1.valueOf() - cfg.offset);
let di = cfg.decimation;
let l = 10000;
const breakLimit = (di ? di*20 : 5 * 60) * 1000;
let trailLength = (ts1 - ts0) / 1000;
return new TripsLayer({
id: 'navl',
data: `/api/navdata?v=${Date.now()}`, // NOTE Not too sure about this
lineWidthMinPixels: 2,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getSourcePosition: (obj, i) => i.index < i.data?.length ? [i.data[i.index]?.longitude, i.data[i.index]?.latitude] : null,
getTargetPosition: (obj, i) => i.index < i.data?.length ? [i.data[i.index+1]?.longitude, i.data[i.index+1]?.latitude] : null,
getLineWidth: 3,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
data: `/api/vessel/track/?di=${di}&l=${l}&project=&ts0=${ts0.toISOString()}&ts1=${ts1.toISOString()}`,
...this.loadOptions({
fetch: {
method: 'GET',
headers: {
Authorization: `Bearer ${this.$store.getters.jwt}`,
}
}
}),
dataTransform: (data) => {
if (data.length >= l) {
console.warn(`Vessel track data may be truncated! Limit: ${l}`);
}
const paths = [];
let prevTstamp;
paths.push({path: [], timestamps: [], num: 0, ts0: +Infinity, ts1: -Infinity});
for (const el of data) {
const tstamp = new Date(el.tstamp).valueOf();
const curPath = () => paths[paths.length-1];
if (prevTstamp && Math.abs(tstamp - prevTstamp) > breakLimit) {
// Start a new path
console.log(`Breaking path on interval ${Math.abs(tstamp - prevTstamp)} > ${breakLimit}`);
paths.push({path: [], timestamps: [], num: paths.length, ts0: +Infinity, ts1: -Infinity});
}
if (tstamp < curPath().ts0) {
curPath().ts0 = tstamp;
}
if (tstamp > curPath().ts1) {
curPath().ts1 = tstamp;
}
curPath().path.push([el.x, el.y]);
curPath().timestamps.push(tstamp/1000);
prevTstamp = tstamp;
}
paths.forEach (path => {
path.nums = paths.length;
path.ts0 = new Date(path.ts0);
path.ts1 = new Date(path.ts1);
});
return paths;
},
getPath: d => d.path,
getTimestamps: d => d.timestamps,
currentTime: ts1.valueOf() / 1000,
trailLength,
widthUnits: "meters",
widthMinPixels: 4,
getWidth: 10,
getColor: [ 174, 1, 126, 200 ],
stroked: true,
pickable: true,
...options
})
});
},
eventsLogLayer (options = {}) {
@@ -308,6 +377,7 @@ export default {
return new DougalEventsLayer({
id: 'log',
data: `/api/project/${this.$route.params.project}/event?mime=application/geo%2Bjson`,
...this.loadOptions(),
lineWidthMinPixels: 2,
getPosition: d => d.geometry.coordinates,
jitter: 0.00015,
@@ -332,6 +402,7 @@ export default {
return new GeoJsonLayer({
id: 'psll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?class=V&v=${this.lineTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getLineWidth: 1,
@@ -347,6 +418,7 @@ export default {
return new GeoJsonLayer({
id: 'ppll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?v=${this.lineTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getLineWidth: 1,
@@ -393,6 +465,7 @@ export default {
return new GeoJsonLayer({
id: 'seqrl',
data: `/api/project/${this.$route.params.project}/gis/raw/line?v=${this.sequenceTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntbp ? [0xe6, 0x51, 0x00, 200] : [0xff, 0x98, 0x00, 200],
getLineWidth: 1,
@@ -408,6 +481,7 @@ export default {
return new GeoJsonLayer({
id: 'seqfl',
data: `/api/project/${this.$route.params.project}/gis/final/line?v=${this.sequenceTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.pending ? [0xa7, 0xff, 0xab, 200] : [0x00, 0x96, 0x88, 200],
getLineWidth: 1,
@@ -424,12 +498,15 @@ export default {
id: 'pslp',
data: `/api/project/${this.$route.params.project}/line/sail?v=${this.lineTStamp?.valueOf()}`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
loadOptions: {
...this.loadOptions({
fetch: {
method: 'GET',
headers: { Accept: 'application/vnd.aaltronav.dougal+octet-stream' }
headers: {
Authorization: `Bearer ${this.$store.getters.jwt}`,
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
}
}
},
}),
getRadius: 2,
getFillColor: (d, {data, index}) => data.attributes.value2.value[index] ? [240, 248, 255, 200] : [85, 170, 255, 200],
//getFillColor: [0, 120, 220, 200],
@@ -443,12 +520,15 @@ export default {
id: 'pplp',
data: `/api/project/${this.$route.params.project}/line/source?v=${this.lineTStamp?.valueOf()}`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
loadOptions: {
...this.loadOptions({
fetch: {
method: 'GET',
headers: { Accept: 'application/vnd.aaltronav.dougal+octet-stream' }
headers: {
Authorization: `Bearer ${this.$store.getters.jwt}`,
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
}
}
},
}),
getRadius: 2,
getFillColor: (d, {data, index}) => data.attributes.value2.value[index] ? [240, 248, 255, 200] : [85, 170, 255, 200],
//getFillColor: [0, 120, 220, 200],

View File

@@ -1,4 +1,5 @@
<script>
import * as d3a from 'd3-array';
export default {
name: "MapTooltipsMixin",
@@ -28,6 +29,8 @@ export default {
return this.sequenceLinesTooltip(args);
} else if (args?.layer?.id == "navp") {
return this.vesselTrackPointsTooltip(args);
} else if (args?.layer?.id == "navl") {
return this.vesselTrackLinesTooltip(args);
}
},
@@ -235,7 +238,20 @@ export default {
}
},
vesselTrackLinesTooltip (args) {
const p = args.object;
console.log("track lines tooltip", p);
if (p) {
let html = `Segment ${p.num+1} / ${p.nums}<br/>\n`
html += `${p.ts0.toISOString()}<br/>\n`
html += `${p.ts1.toISOString()}<br/>\n`;
return {html, style: this.tooltipDefaultStyle};
}
},
}

View File

@@ -69,6 +69,7 @@ const allMeta = (key, value) => {
return { all: [ meta(key, value) ] };
};
//
// NOTICE These routes do not require authentication
//
@@ -103,6 +104,9 @@ app.use(mw.auth.access.user);
// Don't process the request if the data hasn't changed
app.use(mw.etag.ifNoneMatch);
// Use compression across the board
app.use(mw.compress);
// We must be authenticated before we can access these
app.map({
'/project': {
@@ -311,6 +315,30 @@ app.map({
get: [ mw.etag.noSave, mw.gis.navdata.get ]
}
},
'/vessel/track': {
get: [ /*mw.etag.noSave,*/ mw.vessel.track.get ], // JSON array
'/line': {
get: [ // GeoJSON Feature: type = LineString
//mw.etag.noSave,
(req, res, next) => { req.query.geojson = 'LineString'; next(); },
mw.vessel.track.get
]
},
'/point': {
get: [ // GeoJSON FeatureCollection: feature types = Point
//mw.etag.noSave,
(req, res, next) => { req.query.geojson = 'Point'; next(); },
mw.vessel.track.get
]
},
'/points': {
get: [ // JSON array of (Feature: type = Point)
mw.etag.noSave,
(req, res, next) => { req.query.geojson = true; next(); },
mw.vessel.track.get
],
},
},
'/info/': {
':path(*)': {
get: [ mw.auth.operations, mw.info.get ],

View File

@@ -5,8 +5,6 @@ const cfg = require("../../../lib/config").jwt;
const getToken = function (req) {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] == 'Bearer') {
return req.headers.authorization.split(' ')[1];
} else if (req.cookies.JWT) {
return req.cookies.JWT;
}
return null;
}

View File

@@ -0,0 +1,18 @@
const compression = require('compression');
const compress = compression({
level: 6, // Balance speed vs. ratio (1-9)
threshold: 512, // Compress only if response >512 bytes to avoid overhead on small bundles
filter: (req, res) => { // Ensure bundles are compressed
const accept = req.get("Accept");
if (accept.startsWith("application/vnd.aaltronav.dougal+octet-stream")) return true;
if (accept.includes("json")) return true;
if (accept.startsWith("text/")) return true;
if (accept.startsWith("model/obj")) return true;
// fallback to standard filter function
return compression.filter(req, res)
}
});
module.exports = compress;

View File

@@ -11,6 +11,7 @@ module.exports = {
gis: require('./gis'),
label: require('./label'),
navdata: require('./navdata'),
vessel: require('./vessel'),
queue: require('./queue'),
qc: require('./qc'),
configuration: require('./configuration'),
@@ -20,5 +21,6 @@ module.exports = {
rss: require('./rss'),
etag: require('./etag'),
version: require('./version'),
admin: require('./admin')
admin: require('./admin'),
compress: require('./compress'),
};

View File

@@ -8,7 +8,6 @@ async function login (req, res, next) {
if (payload) {
const token = jwt.issue(payload, req, res);
res.set("X-JWT", token);
res.set("Set-Cookie", `JWT=${token}`); // For good measure
res.status(200).send({token});
next();
return;

View File

@@ -1,6 +1,5 @@
async function logout (req, res, next) {
res.clearCookie("JWT");
res.status(204).send();
next();
}

View File

@@ -0,0 +1,4 @@
module.exports = {
track: require('./track'),
}

View File

@@ -0,0 +1,10 @@
const { gis } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
res.status(200).send(await gis.vesseltrack.get(req.query));
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,4 @@
module.exports = {
get: require('./get'),
}

View File

@@ -1,6 +1,7 @@
module.exports = {
project: require('./project'),
navdata: require('./navdata')
navdata: require('./navdata'),
vesseltrack: require('./vesseltrack'),
// line: require('./line')
};

View File

@@ -7,7 +7,10 @@ async function lines (options = {}) {
const client = await pool.connect();
const text = `
SELECT ST_AsGeoJSON(ST_MakeLine(geometry)) geojson
SELECT json_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(ST_MakeLine(geometry))::json
) geojson
FROM (
SELECT geometry
FROM real_time_inputs

View File

@@ -0,0 +1,191 @@
const { pool } = require('../../connection');
const { project } = require('../../utils');
async function get(options = {}) {
/*
* ts0: earliest timestamp (default: NOW - 7 days)
* ts1: latest timestamp (if null, assume NOW)
* di: decimation interval (return every di-th record, if null: no decimation)
* l: limit (return no more than l records, default: 1000, max: 1,000,000)
* geojson: 'LineString' (GeoJSON LineString Feature), 'Point' (GeoJSON FeatureCollection),
* truthy (array of Point features), falsy (array of {x, y, tstamp, meta})
*/
let { l, di, ts1, ts0, geojson, projection } = {
l: 1000,
di: null,
ts1: null,
ts0: null,
geojson: false,
...options
};
// Input validation and sanitization
l = Math.max(1, Math.min(parseInt(l) || 1000, 1000000)); // Enforce 1 <= l <= 1,000,000
di = di != null ? Math.max(1, parseInt(di)) : null; // Ensure di is positive integer or null
ts0 = ts0 ? new Date(ts0).toISOString() : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); // Default: 7 days ago
ts1 = ts1 ? new Date(ts1).toISOString() : null; // Convert to ISO string or null
geojson = geojson === 'LineString' || geojson === 'Point' ? geojson : !!geojson; // Normalize geojson
const client = await pool.connect();
// Build the WHERE clause and values array dynamically
let whereClauses = [];
let values = [];
let paramIndex = 1;
if (ts0) {
whereClauses.push(`tstamp >= $${paramIndex++}`);
values.push(ts0);
}
if (ts1) {
whereClauses.push(`tstamp <= $${paramIndex++}`);
values.push(ts1);
}
// Add limit to values
values.push(l);
const limitClause = `LIMIT $${paramIndex++}`;
// Base query with conditional geometry selection
let queryText = `
SELECT
tstamp,
CASE
WHEN meta->>'lineStatus' = 'offline' THEN
ARRAY[COALESCE((meta->>'longitude')::float, ST_X(geometry)), COALESCE((meta->>'latitude')::float, ST_Y(geometry))]
ELSE
ARRAY[
COALESCE(
(meta->>'longitudeMaster')::float,
ST_X(geometry)
),
COALESCE(
(meta->>'latitudeMaster')::float,
ST_Y(geometry)
)
]
END AS coordinates,
meta::json AS meta
FROM public.real_time_inputs
${whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''}
ORDER BY tstamp DESC
${limitClause}
`;
// If decimation is requested, wrap the query in a subquery with ROW_NUMBER
if (di != null && di > 1) {
values.push(di);
queryText = `
SELECT tstamp, coordinates, meta
FROM (
SELECT
tstamp,
CASE
WHEN meta->>'lineStatus' = 'offline' THEN
ARRAY[COALESCE((meta->>'longitude')::float, ST_X(geometry)), COALESCE((meta->>'latitude')::float, ST_Y(geometry))]
ELSE
ARRAY[
COALESCE(
(meta->>'longitudeMaster')::float,
ST_X(geometry)
),
COALESCE(
(meta->>'latitudeMaster')::float,
ST_Y(geometry)
)
]
END AS coordinates,
meta::json AS meta,
ROW_NUMBER() OVER (ORDER BY tstamp DESC) AS rn
FROM public.real_time_inputs
${whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''}
) sub
WHERE rn % $${paramIndex} = 0
ORDER BY tstamp DESC
${limitClause}
`;
}
try {
const res = await client.query(queryText, values);
if (!res.rows?.length) {
throw { status: 204 }; // No Content
}
// Process rows: Convert tstamp to Date and extract coordinates
let processed = res.rows.map(row => ({
tstamp: new Date(row.tstamp),
x: row.coordinates[0], // Longitude
y: row.coordinates[1], // Latitude
meta: projection != null
? projection == ""
? undefined
: project([row.meta], projection)[0] : row.meta
}));
// Handle geojson output formats
if (geojson === 'LineString') {
// Compute line length (haversine formula in JavaScript for simplicity)
let length = 0;
for (let i = 1; i < processed.length; i++) {
const p1 = processed[i - 1];
const p2 = processed[i];
const R = 6371e3; // Earth's radius in meters
const φ1 = p1.y * Math.PI / 180;
const φ2 = p2.y * Math.PI / 180;
const Δφ = (p2.y - p1.y) * Math.PI / 180;
const Δλ = (p2.x - p1.x) * Math.PI / 180;
const a = Math.sin(Δφ / 2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
length += R * c;
}
return {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: processed.map(p => [p.x, p.y])
},
properties: {
ts0: processed[processed.length - 1].tstamp.toISOString(),
ts1: processed[0].tstamp.toISOString(),
distance: length
}
};
} else if (geojson === 'Point') {
return {
type: 'FeatureCollection',
features: processed.map(p => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [p.x, p.y]
},
properties: {
...p.meta,
tstamp: p.tstamp.toISOString()
}
}))
};
} else if (geojson) {
return processed.map(p => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [p.x, p.y]
},
properties: {
...p.meta,
tstamp: p.tstamp.toISOString()
}
}));
} else {
return processed;
}
} finally {
client.release();
}
}
module.exports = get;

View File

@@ -0,0 +1,5 @@
module.exports = {
get: require('./get')
};

View File

@@ -0,0 +1,94 @@
const { exec } = require('child_process');
const util = require('util');
const fs = require('fs');
const execPromise = util.promisify(exec);
const { setSurvey, pool } = require('../connection');
async function createProject(pid, name, src_srid, dst_srid, src_schema, dst_schema) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Determine default src_schema and src_srid
let src_schema_val;
let src_srid_val;
const res = await client.query(`
SELECT schema, meta->>'epsg' as epsg
FROM public.projects
ORDER BY CAST(SUBSTRING(schema FROM 8) AS INTEGER) DESC
LIMIT 1
`);
if (res.rows.length === 0) {
src_schema_val = 'survey_0';
src_srid_val = 23031;
} else {
src_schema_val = res.rows[0].schema;
src_srid_val = parseInt(res.rows[0].epsg, 10);
}
// Apply parameters or defaults
src_schema = src_schema || src_schema_val;
src_srid = src_srid ?? src_srid_val;
dst_srid = dst_srid ?? src_srid;
if (dst_schema === undefined) {
const srcNum = parseInt(src_schema.replace('survey_', ''), 10);
dst_schema = `survey_${srcNum + 1}`;
}
// Dump the source schema structure
const pgDumpCmd = `PGPASSWORD=${pool.options.password} pg_dump --schema-only --schema=${src_schema} --host=${pool.options.host} --port=${pool.options.port} --username=${pool.options.user} ${pool.options.database}`;
const { stdout: sqlDump } = await execPromise(pgDumpCmd);
//fs.writeFileSync('sqlDump.sql', sqlDump);
//console.log('Saved original SQL to sqlDump.sql');
// Modify the dump to use the destination schema and update SRID
const escapedSrcSchema = src_schema.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
let modifiedSql = sqlDump
.replace(new RegExp(`CREATE SCHEMA ${escapedSrcSchema};`, 'gi'), `CREATE SCHEMA ${dst_schema};`)
.replace(new RegExp(`ALTER SCHEMA ${escapedSrcSchema} OWNER TO`, 'gi'), `ALTER SCHEMA ${dst_schema} OWNER TO`)
.replace(/SELECT pg_catalog\.set_config\('search_path',\s*'', false\);/, `SELECT pg_catalog.set_config('search_path', '${dst_schema}, public', false);`)
.replace(new RegExp(`${escapedSrcSchema}\\.`, 'g'), `${dst_schema}.`);
// Replace SRID in the SQL dump if src_srid !== dst_srid
if (src_srid !== dst_srid) {
// Replace SRID in geometry column definitions (e.g., geometry(Point, 23031))
modifiedSql = modifiedSql.replace(
new RegExp(`geometry\\((\\w+),\\s*${src_srid}\\s*\\)`, 'g'),
`geometry($1, ${dst_srid})`
);
// Replace SRID in AddGeometryColumn calls (if used in the dump)
modifiedSql = modifiedSql.replace(
new RegExp(`AddGeometryColumn\\((['"]?)${escapedSrcSchema}\\1,\\s*(['"]\\w+['"]),\\s*(['"]\\w+['"]),\\s*${src_srid},`, 'g'),
`AddGeometryColumn($1${dst_schema}$1, $2, $3, ${dst_srid},`
);
console.log(`Replaced SRID ${src_srid} with ${dst_srid} in SQL dump`);
}
//fs.writeFileSync('modifiedSql.sql', modifiedSql);
//console.log('Saved modified SQL to modifiedSql.sql');
// Execute the modified SQL to create the cloned schema
await client.query(modifiedSql);
console.log('Applied modified SQL successfully');
// Insert the new project into public.projects
const meta = { epsg: dst_srid.toString() }; // Ensure string for JSONB
await client.query(`
INSERT INTO public.projects (pid, name, schema, meta)
VALUES ($1, $2, $3, $4)
`, [pid, name, dst_schema, meta]);
console.log(`Inserted project ${pid} into public.projects with schema ${dst_schema}`);
await client.query('COMMIT');
console.log('Transaction committed successfully');
} catch (error) {
await client.query('ROLLBACK');
console.error('Transaction rolled back due to error:', error);
throw error;
} finally {
client.release();
console.log('Database client released');
}
}
module.exports = createProject;

View File

@@ -46,10 +46,6 @@ function issue (payload, req, res) {
if (token) {
res.set("X-JWT", token);
const expiry = payload.exp ? (new Date(payload.exp*1000)).toUTCString() : null;
const cookie = expiry
? `JWT=${token}; path=/; SameSite=lax; expires=${expiry}`
: `JWT=${token}; path=/; SameSite=lax`
res.set("Set-Cookie", cookie); // For good measure
}
}

View File

@@ -30,6 +30,7 @@
"@dougal/organisations": "file:../../modules/@dougal/organisations",
"@dougal/user": "file:../../modules/@dougal/user",
"body-parser": "gitlab:aaltronav/contrib/expressjs/body-parser",
"compression": "^1.8.1",
"cookie-parser": "^1.4.5",
"csv": "^6.3.3",
"d3": "^6.7.0",

154
package-lock.json generated
View File

@@ -39,10 +39,12 @@
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/mesh-layers": "^9.1.14",
"@dougal/binary": "file:../../../modules/@dougal/binary",
"@dougal/concurrency": "file:../../../modules/@dougal/concurrency",
"@dougal/organisations": "file:../../../modules/@dougal/organisations",
"@dougal/user": "file:../../../modules/@dougal/user",
"@loaders.gl/obj": "^4.3.4",
"@mdi/font": "^7.2.96",
"buffer": "^6.0.3",
"core-js": "^3.6.5",
@@ -3368,14 +3370,6 @@
"dev": true,
"license": "MIT"
},
"lib/www/client/source/node_modules/bytes": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"lib/www/client/source/node_modules/call-bind": {
"version": "1.0.5",
"dev": true,
@@ -3709,47 +3703,6 @@
"dev": true,
"license": "MIT"
},
"lib/www/client/source/node_modules/compressible": {
"version": "2.0.18",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"lib/www/client/source/node_modules/compression": {
"version": "1.7.4",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.5",
"bytes": "3.0.0",
"compressible": "~2.0.16",
"debug": "2.6.9",
"on-headers": "~1.0.2",
"safe-buffer": "5.1.2",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"lib/www/client/source/node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"lib/www/client/source/node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"dev": true,
"license": "MIT"
},
"lib/www/client/source/node_modules/connect-history-api-fallback": {
"version": "2.0.0",
"dev": true,
@@ -6495,14 +6448,6 @@
"dev": true,
"license": "MIT"
},
"lib/www/client/source/node_modules/on-headers": {
"version": "1.0.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"lib/www/client/source/node_modules/onetime": {
"version": "5.1.2",
"dev": true,
@@ -8641,14 +8586,6 @@
"spdx-expression-parse": "^3.0.0"
}
},
"lib/www/client/source/node_modules/vary": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"lib/www/client/source/node_modules/vue": {
"version": "2.6.14",
"license": "MIT"
@@ -9431,6 +9368,7 @@
"@dougal/organisations": "file:../../modules/@dougal/organisations",
"@dougal/user": "file:../../modules/@dougal/user",
"body-parser": "gitlab:aaltronav/contrib/expressjs/body-parser",
"compression": "^1.8.1",
"cookie-parser": "^1.4.5",
"csv": "^6.3.3",
"d3": "^6.7.0",
@@ -13365,13 +13303,6 @@
"node": ">= 0.4.0"
}
},
"lib/www/server/node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"lib/www/server/node_modules/xtend": {
"version": "4.0.2",
"license": "MIT",
@@ -13493,19 +13424,18 @@
}
},
"node_modules/@deck.gl/mesh-layers": {
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.1.13.tgz",
"integrity": "sha512-ujhe9FtB4qRRCXH/hY5p+IQ5VO/AC+/dtern6CTzYzjGnUnAvsbIgBZ3jxSlb1B/D3wlVE778W2cmv7MIToJJg==",
"peer": true,
"version": "9.1.14",
"resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.1.14.tgz",
"integrity": "sha512-NVUw0yG4stJfrklWCGP9j8bNlf9YQc4PccMeNNIHNrU/Je6/Va6dJZg0RGtVkeaTY1Lk3A7wRzq8/M5Urfvuiw==",
"dependencies": {
"@loaders.gl/gltf": "^4.2.0",
"@luma.gl/gltf": "^9.1.5",
"@luma.gl/shadertools": "^9.1.5"
"@luma.gl/gltf": "~9.1.9",
"@luma.gl/shadertools": "~9.1.9"
},
"peerDependencies": {
"@deck.gl/core": "^9.1.0",
"@luma.gl/core": "^9.1.5",
"@luma.gl/engine": "^9.1.5"
"@luma.gl/core": "~9.1.9",
"@luma.gl/engine": "~9.1.9"
}
},
"node_modules/@dougal/binary": {
@@ -13700,6 +13630,18 @@
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@loaders.gl/obj": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/obj/-/obj-4.3.4.tgz",
"integrity": "sha512-Rdn+NHjLI0jKYrKNicJuQJohnHh7QAv4szCji8eafYYMrVtSIonNozBXUfe/c4V7HL/FVvvHCkfC66rvLvayaQ==",
"dependencies": {
"@loaders.gl/loader-utils": "4.3.4",
"@loaders.gl/schema": "4.3.4"
},
"peerDependencies": {
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@loaders.gl/schema": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz",
@@ -14444,6 +14386,34 @@
"node": ">= 10"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -15983,6 +15953,14 @@
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ=="
},
"node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -16090,6 +16068,14 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -16713,6 +16699,14 @@
"uuid": "bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",

42
sbin/get-ip.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Function to get the internet IP
get_internet_ip() {
INTERFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')
if [ -z "$INTERFACE" ] || [ "$INTERFACE" = "lo" ]; then
return 1
fi
IP=$(ip -4 addr show dev "$INTERFACE" scope global | grep -oP '(?<=inet\s)\d{1,3}(\.\d{1,3}){3}(?=/)' | head -n1)
if [ -z "$IP" ]; then
return 1
fi
echo "$IP"
return 0
}
# Get all global non-loopback IPv4 addresses
ALL_IPS=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d{1,3}(\.\d{1,3}){3}(?=/)')
ARG="${1:-local}"
if [ "$ARG" = "internet" ]; then
INTERNET_IP=$(get_internet_ip)
if [ $? -ne 0 ]; then
echo "No valid default route or global IPv4 address found."
exit 1
fi
echo "$INTERNET_IP"
else
# For "local" or anything else
INTERNET_IP=$(get_internet_ip) || INTERNET_IP="" # If fails, set to empty so no exclusion
for IP in $ALL_IPS; do
if [ "$IP" != "$INTERNET_IP" ]; then
echo "$IP"
fi
done
fi

40
sbin/update-dns.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# Path to Nginx configuration file
NGINX_CONFIG="/etc/nginx/vhosts.d/dougal.conf"
# Extract the first hostname matching 'lan.dougal' from the config
# Assumes server_name lines like: server_name hostname1 hostname2;
HOSTNAME=$(grep -oE '[a-zA-Z0-9.-]*lan\.dougal[a-zA-Z0-9.-]*' "$NGINX_CONFIG" | head -n 1)
if [ -z "$HOSTNAME" ]; then
echo "Error: No matching hostname found in $NGINX_CONFIG"
exit 1
fi
# Path to IP retrieval script
IP_SCRIPT="$HOME/software/sbin/get-ip.sh"
# Get the current IPv4 address
IP_ADDRESS=$("$IP_SCRIPT" |head -n1)
if [ -z "$IP_ADDRESS" ]; then
echo "Error: Failed to retrieve IP address from $IP_SCRIPT"
exit 1
fi
# Check for DYNDNS_PASSWD environment variable
if [ -z "$DYNDNS_PASSWD" ]; then
echo "Error: DYNDNS_PASSWD environment variable is not set"
exit 1
fi
# Hurricane Electric DynDNS update URL
UPDATE_URL="https://dyn.dns.he.net/nic/update?hostname=$HOSTNAME&password=$DYNDNS_PASSWD&myip=$IP_ADDRESS"
# Send the update request and capture the response
RESPONSE=$(curl -s "$UPDATE_URL")
# Output the response for logging/debugging
echo "Update response for $HOSTNAME ($IP_ADDRESS): $RESPONSE"