pull/1/head
Manuel Kasper 2020-08-14 10:25:45 +02:00
rodzic 62b7d809bb
commit 2dd4d9c7f5
26 zmienionych plików z 4539 dodań i 1 usunięć

Wyświetl plik

@ -1,2 +1,3 @@
# sotlas-api
SOTLAS backend
Backend server for SOTLAS (https://sotl.as), serving summit and activator data to the frontend, handling photo uploads and distributing SOTAwatch spots asynchronously via WebSocket.

78
activations.js 100644
Wyświetl plik

@ -0,0 +1,78 @@
const axios = require('axios');
const moment = require('moment');
const express = require('express');
const config = require('./config');
const db = require('./db');
const summits = require('./summits');
let router = express.Router();
module.exports = router;
router.get('/:callsign', (req, res) => {
res.cacheControl = {
noCache: true
};
db.getDb().collection('activators').findOne({callsign: req.params.callsign}, (err, activator) => {
if (err) {
res.status(500).end();
return;
}
if (!activator) {
res.status(404).end();
return;
}
axios.get('https://api-db.sota.org.uk/admin/activator_log_by_id', { params: { id: activator.userId, year: 'all' } })
.then(response => {
let activations = response.data.map(activation => {
return {
id: activation[0],
date: moment.utc(activation.ActivationDate).toDate(),
callsignUsed: activation.OwnCallsign,
qsos: activation.QSOs,
modeQsos: extractModeQsos(activation),
bandQsos: extractBandQsos(activation),
points: activation.Points,
bonus: activation.BonusPoints,
summit: {
code: activation.Summit.substring(0, activation.Summit.indexOf(' '))
}
}
});
summits.lookupSummits(activations)
.then(activationsWithSummits => {
res.json(activationsWithSummits);
})
})
.catch(error => {
console.error(error);
if (error.response && error.response.status === 401) {
res.status(401);
} else {
res.status(500);
}
res.end();
return;
})
});
});
function extractModeQsos(activation) {
return {
'cw': activation.QSOcw,
'ssb': activation.QSOssb,
'fm': activation.QSOfm
}
}
function extractBandQsos(activation) {
let bands = ['160','80','60','40','30','20','17','15','12','10','6','4','2','70c','23c'];
let bandQsos = {};
bands.forEach(band => {
if (activation['QSO' + band] > 0) {
bandQsos[band + 'm'] = activation['QSO' + band];
}
});
return bandQsos;
}

82
alerts.js 100644
Wyświetl plik

@ -0,0 +1,82 @@
const axios = require('axios');
const express = require('express');
const config = require('./config');
const summits = require('./summits');
let router = express.Router();
module.exports = router;
let alertCache = [];
let lastLoadDate;
let pendingLoad;
router.get('/', (req, res) => {
res.cacheControl = {
noCache: true
};
loadAlerts(req.query.noCache)
.then(alerts => {
res.json(alerts);
})
.catch(err => {
console.error(err);
res.status(500).end();
})
});
function loadAlerts(noCache) {
if (noCache) {
console.log('load alerts (no cache)');
return loadAlertsDirect();
}
if (lastLoadDate && (new Date() - lastLoadDate) < config.alerts.minUpdateInterval) {
return Promise.resolve(alertCache);
}
if (!pendingLoad) {
console.log('load alerts (cache)');
pendingLoad = loadAlertsDirect()
.then(response => {
pendingLoad = null;
return response;
})
.catch(err => {
pendingLoad = null;
return Promise.reject(err);
})
}
return pendingLoad;
}
function loadAlertsDirect() {
return axios.get(config.alerts.url)
.then(response => {
if (response.status !== 200) {
console.error(`Got status ${response.status} when loading alerts`);
return Promise.reject('Cannot load alerts from SOTAwatch');
}
let newAlerts = response.data.map(alert => {
return {
id: alert.id,
timeStamp: new Date(alert.timeStamp),
dateActivated: new Date(alert.dateActivated),
summit: {code: alert.associationCode + '/' + alert.summitCode},
activatorCallsign: alert.activatingCallsign.toUpperCase().replace(/[^A-Z0-9\/-]/g, ''),
posterCallsign: alert.posterCallsign,
frequency: alert.frequency,
comments: alert.comments !== '(null)' ? alert.comments : ''
};
});
return summits.lookupSummits(newAlerts)
.then(alerts => {
alertCache = alerts;
lastLoadDate = new Date();
return alerts;
});
})
}

81
config.js 100644
Wyświetl plik

@ -0,0 +1,81 @@
var config = {};
module.exports = config;
config.http = {
host: '127.0.0.1',
port: 8081
};
config.mongodb = {
url: 'mongodb://sotlas:XXXXXXXX@localhost:27017/sotlas',
dbName: 'sotlas',
batchSize: 1000
};
config.sotaspots = {
initialLoadSpots: -24,
periodicLoadSpots: 100,
maxSpotAge: 86400000,
updateInterval: 30000,
url: 'https://api2.sota.org.uk/api/spots'
};
config.alerts = {
minUpdateInterval: 60000,
url: 'https://api2.sota.org.uk/api/alerts/12'
};
config.rbn = {
server: {
host: 'telnet.reversebeacon.net',
port: 7000
},
login: "HB9DQM-3",
timeout: 180000,
maxSpotHistory: 1000
};
config.geoip = {
path: 'GeoLite2-City.mmdb'
};
config.summitListUrl = 'https://www.sotadata.org.uk/summitslist.csv';
config.sotatrailsUrl = 'https://sotatrails.ch/api.php';
config.photos = {
paths: {
original: '/data/images/photos/original',
thumb: '/data/images/photos/thumb',
large: '/data/images/photos/large'
},
sizes: {
large: {
width: 1600,
height: 1600
},
thumb: {
width: 512,
height: 256
}
},
uploadPath: '/data/upload/photos'
};
config.tracks = {
paths: {
original: '/data/tracks/original',
simple: '/data/tracks/simple'
},
tolerance: 0.00001,
uploadPath: '/data/upload/tracks'
};
config.sso = {
jwksUri: 'https://sso.sota.org.uk/auth/realms/SOTA/protocol/openid-connect/certs'
};
config.mail = {
host: "neon1.net",
port: 587
};

28
db.js 100644
Wyświetl plik

@ -0,0 +1,28 @@
const MongoClient = require('mongodb').MongoClient;
const config = require('./config');
const assert = require('assert');
let db = null
let client
const connectPromise = new Promise((resolve, reject) => {
client = new MongoClient(config.mongodb.url, {useUnifiedTopology: true})
client.connect(function (err) {
assert.equal(null, err)
db = client.db(config.mongodb.dbName)
resolve()
});
});
exports.getDb = function() {
return db
}
exports.waitDb = function(callback) {
connectPromise.then(callback)
}
exports.closeDb = function() {
if (client) {
client.close()
}
}

289
geoexport.js 100644
Wyświetl plik

@ -0,0 +1,289 @@
const axios = require('axios');
const moment = require('moment');
const express = require('express');
const config = require('./config');
const db = require('./db');
let router = express.Router();
module.exports = router;
router.get('/associations/:association.gpx', (req, res) => {
res.cacheControl = {
noCache: true
};
res.set('Content-Type', 'application/gpx+xml');
res.set('Content-Disposition', 'attachment; filename="' + req.params.association + '.gpx"');
gpxForQuery('^' + req.params.association + '/', `SOTA Association ${req.params.association}`, req.query, (err, gpx) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
res.send(gpx).end();
});
});
router.get('/associations/:association.kml', (req, res) => {
res.cacheControl = {
noCache: true
};
res.set('Content-Type', 'application/vnd.google-earth.kml+xml');
res.set('Content-Disposition', 'attachment; filename="' + req.params.association + '.kml"');
kmlForAssociation(req.params.association, req.query, (err, kml) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
if (!kml) {
res.status(404).end();
return;
}
res.send(kml).end();
});
});
router.get('/regions/:association/:region.gpx', (req, res) => {
res.cacheControl = {
noCache: true
};
res.set('Content-Type', 'application/gpx+xml');
res.set('Content-Disposition', 'attachment; filename="' + req.params.association + '_' + req.params.region + '.gpx"');
gpxForQuery('^' + req.params.association + '/' + req.params.region + '-', `SOTA Region ${req.params.association + '/' + req.params.region}`, req.query, (err, gpx) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
res.send(gpx).end();
});
});
router.get('/regions/:association/:region.kml', (req, res) => {
res.cacheControl = {
noCache: true
};
res.set('Content-Type', 'application/vnd.google-earth.kml+xml');
res.set('Content-Disposition', 'attachment; filename="' + req.params.association + '_' + req.params.region + '.kml"');
kmlForRegion(req.params.association, req.params.region, req.query, (err, kml) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
if (!kml) {
res.status(404).end();
return;
}
res.send(kml).end();
});
});
function gpxForQuery(query, name, options, callback) {
let filter = {code: {$regex: query}};
if (!options.inactive) {
filter.validFrom = {$lte: new Date()};
filter.validTo = {$gte: new Date()};
}
db.getDb().collection('summits').find(filter).sort({code: 1}).toArray((err, summits) => {
if (err) {
callback(err);
return;
}
let minlat, minlon, maxlat, maxlon;
summits.forEach(summit => {
if (!minlat || summit.coordinates.latitude < minlat) {
minlat = summit.coordinates.latitude;
}
if (!minlon || summit.coordinates.longitude < minlon) {
minlon = summit.coordinates.longitude;
}
if (!maxlat || summit.coordinates.latitude > maxlat) {
maxlat = summit.coordinates.latitude;
}
if (!maxlon || summit.coordinates.longitude > maxlon) {
maxlon = summit.coordinates.longitude;
}
});
let now = moment.utc();
let gpx = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<gpx version="1.1"
creator="SOTLAS"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>${name}</name>
<author>
<name>SOTLAS</name>
</author>
<link href="https://sotl.as">
<text>SOTLAS</text>
</link>
<time>${now.toISOString()}</time>
<bounds minlat="${minlat}" minlon="${minlon}" maxlat="${maxlat}" maxlon="${maxlon}"/>
</metadata>
`;
summits.forEach(summit => {
gpx += ` <wpt lat="${summit.coordinates.latitude}" lon="${summit.coordinates.longitude}">
<ele>${summit.altitude}</ele>
<name><![CDATA[${summitName(summit, options)}]]></name>
<cmt><![CDATA[${summit.name}]]></cmt>
<sym>SOTA${('0' + summit.points).substr(-2)}</sym>
<type>Summit</type>
</wpt>
`;
});
gpx += "</gpx>";
callback(null, gpx);
});
}
function kmlForAssociation(associationCode, options, callback) {
db.getDb().collection('associations').findOne({code: associationCode}, (err, association) => {
if (err) {
callback(err);
return;
}
if (!association) {
callback(null, null);
return;
}
let filter = {code: {$regex: "^" + association.code + "/"}};
if (!options.inactive) {
filter.validFrom = {$lte: new Date()};
filter.validTo = {$gte: new Date()};
}
db.getDb().collection('summits').find(filter).sort({code: 1}).toArray((err, summits) => {
let now = moment.utc();
let kmlName = 'SOTA Association ' + association.code + ' - ' + association.name;
let kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:atom="http://www.w3.org/2005/Atom">
<Document>
<atom:author><![CDATA[SOTLAS]]></atom:author>
<atom:link href="https://sotl.as/summits/${association.code}"/>
<name><![CDATA[${kmlName}]]></name>
<TimeStamp>
<when>${now.toISOString()}</when>
</TimeStamp>
`;
association.regions.forEach(region => {
kml += ` <Folder>
<name><![CDATA[${association.code}/${region.code} - ${region.name}]]></name>
<atom:link href="https://sotl.as/summits/${association.code}/${region.code}"/>
`;
summits.filter(summit => {return summit.code.startsWith(association.code + '/' + region.code)}).forEach(summit => {
kml += kmlForSummit(summit, options);
});
kml += ` </Folder>
`;
});
kml += ` </Document>
</kml>
`;
callback(null, kml);
});
});
}
function kmlForRegion(associationCode, regionCode, options, callback) {
db.getDb().collection('associations').findOne({code: associationCode}, (err, association) => {
if (err) {
callback(err);
return;
}
if (!association) {
callback(null, null);
return;
}
let filter = {code: {$regex: "^" + association.code + "/" + regionCode + '-'}};
if (!options.inactive) {
filter.validFrom = {$lte: new Date()};
filter.validTo = {$gte: new Date()};
}
db.getDb().collection('summits').find(filter).sort({code: 1}).toArray((err, summits) => {
let now = moment.utc();
let kmlName = 'SOTA Region ' + associationCode + '/' + regionCode;
let kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:atom="http://www.w3.org/2005/Atom">
<Document>
<atom:author><![CDATA[SOTLAS]]></atom:author>
<TimeStamp>
<when>${now.toISOString()}</when>
</TimeStamp>
`;
association.regions.forEach(region => {
if (regionCode && region.code !== regionCode) {
return;
}
kml += ` <name>SOTA Region <![CDATA[${association.code}/${region.code} - ${region.name}]]></name>
<atom:link href="https://sotl.as/summits/${association.code}/${region.code}"/>
`;
summits.filter(summit => {return summit.code.startsWith(association.code + '/' + region.code)}).forEach(summit => {
kml += kmlForSummit(summit, options);
});
});
kml += ` </Document>
</kml>
`;
callback(null, kml);
});
});
}
function summitName(summit, options) {
let name = summit.code;
let nameopts = [];
if (options.nameopts) {
nameopts = options.nameopts.split(',')
}
if (nameopts.includes('name')) {
name += ' - ' + summit.name;
}
if (nameopts.includes('altitude')) {
name += ', ' + summit.altitude + 'm';
}
if (nameopts.includes('points')) {
name += ', ' + summit.points + 'pt';
}
return name;
}
function kmlForSummit(summit, options) {
return ` <Placemark id="${summit.code}">
<name><![CDATA[${summitName(summit, options)}]]></name>
<description><![CDATA[${summit.name}, ${summit.altitude}m, ${summit.points}pt]]></description>
<Point>
<coordinates>${summit.coordinates.longitude},${summit.coordinates.latitude},${summit.altitude}</coordinates>
</Point>
</Placemark>
`;
}

66
keyzipper.js 100644
Wyświetl plik

@ -0,0 +1,66 @@
const KEY_DECOMPRESSION_MAP = {
a: 'altitude',
ac: 'activatorCallsign',
ao: 'activationCount',
c: 'comments',
d: 'code',
e: 'speed',
f: 'frequency',
hc: 'homeCallsign',
i: 'isActivator',
ic: 'isoCode',
l: 'callsign',
m: 'mode',
n: 'name',
o: 'continent',
p: 'points',
s: 'summit',
t: 'spotter',
ts: 'timeStamp'
}
let KEY_COMPRESSION_MAP = null
function compressKeys (obj) {
// Lazy init
if (KEY_COMPRESSION_MAP === null) {
KEY_COMPRESSION_MAP = {}
Object.keys(KEY_DECOMPRESSION_MAP).forEach(key => {
KEY_COMPRESSION_MAP[KEY_DECOMPRESSION_MAP[key]] = key
})
}
return mapKeys(obj, KEY_COMPRESSION_MAP)
}
function decompressKeys (obj) {
return mapKeys(obj, KEY_DECOMPRESSION_MAP)
}
function mapKeys (obj, map) {
if (obj === null) {
return null
} else if (Array.isArray(obj)) {
return obj.map(el => {
return mapKeys(el, map)
})
} else if (typeof obj === 'object' && !(obj instanceof Date)) {
let ret = {}
Object.keys(obj).forEach(key => {
let val = mapKeys(obj[key], map)
if (map[key]) {
ret[map[key]] = val
} else {
ret[key] = val
}
})
return ret
} else {
return obj
}
}
module.exports = {
compressKeys, decompressKeys
}

2177
package-lock.json wygenerowano 100644

Plik diff jest za duży Load Diff

39
package.json 100644
Wyświetl plik

@ -0,0 +1,39 @@
{
"name": "sotlas-api",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@turf/simplify": "^5.1.5",
"axios": "^0.19.0",
"carrier": "^0.3.0",
"csv-parse": "^4.6.3",
"diacritics": "^1.3.0",
"exif-parser": "^0.1.12",
"exif-reader": "^1.0.3",
"express": "^4.17.1",
"express-bearer-token": "^2.4.0",
"express-cache-controller": "^1.1.0",
"express-jwt": "^5.3.1",
"express-ws": "^4.0.0",
"hasha": "^5.1.0",
"htmlparser2": "^3.10.1",
"jwks-rsa": "^1.6.0",
"maxmind": "^3.1.2",
"moment": "^2.24.0",
"mongodb": "^3.5.6",
"multer": "^1.4.2",
"nodemailer": "^6.4.6",
"reconnect-net": "^1.1.1",
"request": "^2.88.0",
"sharp": "^0.23.4",
"togeojson": "^0.16.0",
"togpx": "^0.5.4",
"treemap-js": "^1.2.1"
}
}

108
photos.js 100644
Wyświetl plik

@ -0,0 +1,108 @@
const sharp = require('sharp')
const crypto = require('crypto')
const fs = require('fs')
const fsPromises = require('fs').promises
const exif = require('exif-reader')
const path = require('path')
const hasha = require('hasha')
const config = require('./config')
const db = require('./db')
module.exports = {
importPhoto: async function(filename, author) {
// Hash input file to determine filename
let hash = await hasha.fromFile(filename, {algorithm: 'sha256'})
let hashFilename = hash.substr(0, 32) + '.jpg'
let originalPath = config.photos.paths.original + '/' + hashFilename.substr(0, 2) + '/' + hashFilename
await fsPromises.mkdir(path.dirname(originalPath), {recursive: true})
let metadata = await getMetadata(filename)
if (metadata.format !== 'jpeg' && metadata.format != 'png' && metadata.format != 'heif') {
throw new Error('Bad input format, must be JPEG, PNG or HEIF')
}
await fsPromises.copyFile(filename, originalPath)
let photo = {
filename: hashFilename,
width: Math.round(metadata.width),
height: Math.round(metadata.height),
author,
uploadDate: new Date()
}
if (metadata.orientation && metadata.orientation >= 5) {
// Swap width/height
let tmp = photo.width
photo.width = photo.height
photo.height = tmp
}
if (metadata.exif) {
let exifParsed = exif(metadata.exif)
if (exifParsed) {
if (exifParsed.gps && exifParsed.gps.GPSLatitude && exifParsed.gps.GPSLongitude &&
(!exifParsed.gps.GPSStatus || exifParsed.gps.GPSStatus === 'A') &&
!isNaN(exifParsed.gps.GPSLatitude[0]) && !isNaN(exifParsed.gps.GPSLongitude[0])) {
photo.coordinates = {}
photo.coordinates.latitude = exifParsed.gps.GPSLatitude[0] + exifParsed.gps.GPSLatitude[1]/60 + exifParsed.gps.GPSLatitude[2]/3600
if (exifParsed.gps.GPSLatitudeRef === 'S') {
photo.coordinates.latitude = -photo.coordinates.latitude
}
photo.coordinates.longitude = exifParsed.gps.GPSLongitude[0] + exifParsed.gps.GPSLongitude[1]/60 + exifParsed.gps.GPSLongitude[2]/3600
if (exifParsed.gps.GPSLongitudeRef === 'W') {
photo.coordinates.longitude = -photo.coordinates.longitude
}
if (exifParsed.gps.GPSImgDirection && exifParsed.gps.GPSImgDirection >= 0 && exifParsed.gps.GPSImgDirection < 360) {
photo.direction = Math.round(exifParsed.gps.GPSImgDirection)
}
if (exifParsed.gps.GPSHPositioningError) {
photo.positioningError = Math.round(exifParsed.gps.GPSHPositioningError)
}
}
if (exifParsed.image && exifParsed.image.Make && exifParsed.image.Model) {
photo.camera = exifParsed.image.Make + ' ' + exifParsed.image.Model
}
if (exifParsed.exif) {
if (exifParsed.exif.DateTimeDigitized) {
photo.date = exifParsed.exif.DateTimeDigitized
} else if (exifParsed.exif.DateTimeOriginal) {
photo.date = exifParsed.exif.DateTimeOriginal
}
}
}
}
let mkdirTasks = []
let resizeTasks = []
Object.keys(config.photos.sizes).forEach(sizeDescr => {
let outPath = config.photos.paths[sizeDescr] + '/' + hashFilename.substr(0, 2) + '/' + hashFilename
mkdirTasks.push(fsPromises.mkdir(path.dirname(outPath), {recursive: true}))
resizeTasks.push(makeResized(originalPath, outPath, config.photos.sizes[sizeDescr].width, config.photos.sizes[sizeDescr].height))
})
await Promise.all(mkdirTasks)
await Promise.all(resizeTasks)
db.getDb().collection('uploads').insertOne({
uploadDate: new Date(),
type: 'photo',
filename: hashFilename,
author
})
return photo
}
}
function getMetadata(src) {
return sharp(src).metadata()
}
function makeResized(src, dst, maxWidth, maxHeight) {
return sharp(src).rotate().resize({ height: maxHeight, width: maxWidth, fit: 'inside' }).toFile(dst)
}

216
photos_router.js 100644
Wyświetl plik

@ -0,0 +1,216 @@
const express = require('express')
const multer = require('multer')
const config = require('./config')
const photos = require('./photos')
const jwt = require('express-jwt')
const jwksRsa = require('jwks-rsa')
const nodemailer = require('nodemailer')
const db = require('./db')
let upload = multer({dest: config.photos.uploadPath})
let router = express.Router()
module.exports = router
let jwtCallback = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: config.sso.jwksUri
})
})
router.post('/summits/:association/:code/upload', jwtCallback, upload.array('photo'), async (req, res) => {
try {
res.cacheControl = {
noCache: true
}
if (!req.user.callsign) {
res.status(401).send('Missing callsign in SSO token').end()
return
}
let summitCode = req.params.association + '/' + req.params.code
let summit = await db.getDb().collection('summits').findOne({code: summitCode})
if (!summit) {
res.status(404).end()
return
}
if (req.files) {
let dbPhotos = []
for (let file of req.files) {
let photo = await photos.importPhoto(file.path, req.user.callsign)
dbPhotos.push(photo)
}
// Check for duplicates
if (summit.photos) {
dbPhotos = dbPhotos.filter(photo => !summit.photos.some(summitPhoto => summitPhoto.filename === photo.filename ))
}
if (dbPhotos.length > 0) {
await db.getDb().collection('summits').updateOne({code: summitCode}, { $push: { photos: { $each: dbPhotos } } })
let transporter = nodemailer.createTransport(config.mail)
transporter.sendMail({
from: 'api@sotl.as',
to: 'mk@neon1.net',
subject: 'New photos added to summit ' + summitCode + ' by ' + req.user.callsign,
text: `${dbPhotos.length} new photos have been added. https://sotl.as/summits/${summitCode}\n`,
attachments: dbPhotos.map(photo => {
return {
filename: photo.filename,
path: config.photos.paths.thumb + '/' + photo.filename.substr(0, 2) + '/' + photo.filename
}
})
})
}
res.json(dbPhotos)
} else {
res.status(400).end()
}
} catch (err) {
console.error(err)
res.status(500).end()
}
})
router.delete('/summits/:association/:code/:filename', jwtCallback, async (req, res) => {
res.cacheControl = {
noCache: true
}
if (!req.user.callsign) {
res.status(401).send('Missing callsign in SSO token').end()
return
}
let summitCode = req.params.association + '/' + req.params.code
let summit = await db.getDb().collection('summits').findOne({code: summitCode})
let photo = summit.photos.find(photo => photo.filename === req.params.filename)
if (!photo) {
res.status(404).end()
return
}
// Check that uploader is currently logged in user
if (photo.author !== req.user.callsign) {
res.status(401).send('Cannot delete another user\'s photos').end()
return
}
await db.getDb().collection('summits').updateOne({code: summitCode}, { $pull: { photos: { filename: req.params.filename } } })
res.status(204).end()
})
router.post('/summits/:association/:code/reorder', jwtCallback, async (req, res) => {
res.cacheControl = {
noCache: true
}
if (!req.user.callsign) {
res.status(401).send('Missing callsign in SSO token').end()
return
}
let summitCode = req.params.association + '/' + req.params.code
// Assign new sortOrder index to photos of this user, in the order given by req.body.filenames
let updates = req.body.filenames.map((filename, index) => {
return db.getDb().collection('summits').updateOne(
{ code: summitCode, 'photos.author': req.user.callsign, 'photos.filename': filename },
{ $set: { 'photos.$.sortOrder': index + 1 } }
)
})
await Promise.all(updates)
res.status(204).end()
})
router.post('/summits/:association/:code/:filename', jwtCallback, async (req, res) => {
res.cacheControl = {
noCache: true
}
if (!req.user.callsign) {
res.status(401).send('Missing callsign in SSO token').end()
return
}
let summitCode = req.params.association + '/' + req.params.code
let summit = await db.getDb().collection('summits').findOne({code: summitCode})
let photo = summit.photos.find(photo => photo.filename === req.params.filename)
if (!photo) {
res.status(404).end()
return
}
// Check that editor is the currently logged in user
if (photo.author !== req.user.callsign) {
res.status(401).send('Cannot delete another user\'s photos').end()
return
}
let update = {
$set: {},
$unset: {}
}
if (req.body.title) {
update.$set['photos.$.title'] = req.body.title
} else {
update.$unset['photos.$.title'] = ''
}
if (req.body.date) {
update.$set['photos.$.date'] = new Date(req.body.date)
} else {
update.$unset['photos.$.date'] = ''
}
if (req.body.coordinates) {
update.$set['photos.$.coordinates'] = req.body.coordinates
update.$set['photos.$.positioningError'] = req.body.positioningError
} else {
update.$unset['photos.$.coordinates'] = ''
update.$unset['photos.$.positioningError'] = ''
}
if (req.body.direction !== null && req.body.direction !== undefined && req.body.direction !== '') {
update.$set['photos.$.direction'] = req.body.direction
} else {
update.$unset['photos.$.direction'] = ''
}
if (req.body.isCover) {
update.$set['photos.$.isCover'] = true
// Only one photo can be the cover photo, so unmark all others first
await db.getDb().collection('summits').updateOne(
{ code: summitCode },
{ $unset: { 'photos.$[].isCover': '' } }
)
} else {
update.$unset['photos.$.isCover'] = ''
}
if (Object.keys(update.$set).length === 0) {
delete update.$set
}
if (Object.keys(update.$unset).length === 0) {
delete update.$unset
}
await db.getDb().collection('summits').updateOne(
{ code: summitCode, 'photos.filename': req.params.filename },
update
)
res.status(204).end()
})

158
rbn.js 100644
Wyświetl plik

@ -0,0 +1,158 @@
const reconnect = require('reconnect-net');
const wsManager = require('./ws-manager');
const carrier = require('carrier');
const config = require('./config');
const db = require('./db');
const utils = require('./utils');
const rbnSpotRegex = /^DX de (\S+):\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+dB\s+(\S+)\s+\S+\s+(CQ|DX)\s+(\d+)Z$/;
class RbnReceiver {
start() {
this.restartConnection();
wsManager.on('message', (ws, message) => {
if (message.rbnFilter !== undefined) {
console.log("Set RBN filter to " + JSON.stringify(message.rbnFilter));
ws.rbnFilter = message.rbnFilter;
this.sendSpotHistory(ws)
}
});
}
restartConnection() {
if (this.re)
this.re.disconnect();
this.resetTimer();
this.re = reconnect((stream) => {
console.log("Connected to RBN");
stream.write(config.rbn.login + "\r\n");
if (config.rbn.server.commands) {
config.rbn.server.commands.forEach(command => {
stream.write(command + "\r\n");
});
}
carrier.carry(stream, (line) => {
this.handleLine(line);
});
});
this.re.on('error', (err) => {
console.error(`RBN connection error: ${err}`);
});
this.re.connect(config.rbn.server);
}
resetTimer() {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
console.error("RBN: timeout, reconnecting");
this.restartConnection();
}, config.rbn.timeout);
}
handleLine(line) {
this.resetTimer();
let matches = rbnSpotRegex.exec(line);
if (matches) {
let spot = {
timeStamp: new Date(),
callsign: matches[3],
homeCallsign: this.homeCallsign(matches[3]),
spotter: matches[1].replace("-#", ""),
frequency: parseFloat((matches[2]/1000).toFixed(4)),
mode: matches[4],
snr: parseInt(matches[5]),
speed: parseInt(matches[6])
};
// Check if this is a known SOTA activator
db.getDb().collection('activators').countDocuments({callsign: { $in: utils.makeCallsignVariations(spot.homeCallsign) }}, (error, result) => {
if (result > 0) {
spot.isActivator = true;
}
db.getDb().collection('rbnspots').insertOne(spot, (error, result) => {
// _id has now been added, but not in our preferred format
spot._id = spot._id.toHexString()
wsManager.broadcast({'rbnSpot': spot}, (ws) => {
if (!ws.rbnFilter) {
return false;
}
if (ws.rbnFilter.homeCallsign && ws.rbnFilter.homeCallsign.includes(spot.homeCallsign)) {
return true;
}
if (ws.rbnFilter.isActivator && spot.isActivator) {
return true;
}
return false;
});
});
});
}
}
sendSpotHistory(ws) {
// Send the spot history for the currently defined RBN filter
if (!ws.rbnFilter.homeCallsign && !ws.rbnFilter.isActivator) {
return;
}
let query = {};
if (ws.rbnFilter.homeCallsign) {
query.homeCallsign = ws.rbnFilter.homeCallsign;
}
if (ws.rbnFilter.isActivator) {
query.isActivator = true;
}
let maxAge = parseInt(ws.rbnFilter.maxAge) || 3600000;
query.timeStamp = {$gte: new Date(new Date().getTime() - maxAge)};
db.getDb().collection('rbnspots').find(query).sort({timeStamp: -1}).limit(config.rbn.maxSpotHistory).toArray((err, rbnSpots) => {
if (err) {
console.error(err);
return;
}
rbnSpots.forEach(spot => {
spot._id = spot._id.toHexString();
});
let response = {rbnSpotHistory: rbnSpots};
if (ws.rbnFilter.viewId) {
response.viewId = ws.rbnFilter.viewId;
}
wsManager.unicast(response, ws);
});
}
homeCallsign(callsign) {
let parts = callsign.split('/');
let longestPart = '';
parts.forEach(part => {
if (part.length > longestPart.length) {
longestPart = part;
}
})
// For UK callsigns, normalize them all to 2E/G/M for the sake of comparison
let matches = longestPart.match(/^(2[DEIJMUW]|G[DIJMUW]?|M[DIJMUW]?)(\d[A-Z]{2,3})$/)
if (matches) {
longestPart = matches[1].replace(/^2./, '2E').replace(/^G[DIJMUW]/, 'G').replace(/^M[DIJMUW]/, 'M') + matches[2]
}
return longestPart;
}
}
module.exports = RbnReceiver;

266
server.js 100644
Wyświetl plik

@ -0,0 +1,266 @@
const express = require('express');
const config = require('./config');
const assert = require('assert');
const app = express();
const expressWs = require('express-ws')(app);
const cacheControl = require('express-cache-controller');
const bearerToken = require('express-bearer-token');
const wsManager = require('./ws-manager');
const SotaSpotReceiver = require('./sotaspots');
const RbnReceiver = require('./rbn');
const db = require('./db');
const alerts = require('./alerts');
const geoexport = require('./geoexport');
const activations = require('./activations');
const utils = require('./utils');
const photos_router = require('./photos_router')
const tracks_router = require('./tracks_router')
const maxmind = require('maxmind');
let dbChecker = (req, res, next) => {
if (db.getDb() == null) {
console.error('DB error');
res.status(500).end();
return;
}
next();
};
app.enable('trust proxy');
app.use(express.json());
app.use(dbChecker);
app.use(cacheControl({
maxAge: 3600
}));
app.use(bearerToken());
app.use('/ws', wsManager.router);
app.use('/alerts', alerts);
app.use('/geoexport', geoexport);
app.use('/activations', activations);
app.use('/photos', photos_router);
app.use('/tracks', tracks_router);
let sotaSpotReceiver = new SotaSpotReceiver();
let rbnReceiver = new RbnReceiver();
rbnReceiver.start();
app.get('/summits/search', (req, res) => {
let limit = 100;
if (req.query.limit) {
let limitOverride = parseInt(req.query.limit);
if (limitOverride > 0 && limitOverride < limit) {
limit = limitOverride;
}
}
db.getDb().collection('summits').find({$or: [{code: {'$regex': req.query.q, '$options': 'i'}}, {name: {'$regex': req.query.q, '$options': 'i'}}, {nameNd: {'$regex': req.query.q, '$options': 'i'}}]}, {projection: {'_id': false, 'photos': false, 'routes': false, 'links': false, 'resources': false}}).limit(limit).toArray((err, summits) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
res.json(summits);
});
});
app.get('/summits/near', (req, res) => {
let limit = 100;
if (req.query.limit) {
let limitOverride = parseInt(req.query.limit);
if (limitOverride > 0 && limitOverride < limit) {
limit = limitOverride;
}
}
let query = {
coordinates: {$near: {$geometry: {type: "Point", coordinates: [parseFloat(req.query.lon), parseFloat(req.query.lat)]}}}
};
if (req.query.maxDistance) {
query.coordinates.$near.$maxDistance = parseFloat(req.query.maxDistance);
}
if (!req.query.inactive) {
query.validFrom = {$lte: new Date()};
query.validTo = {$gte: new Date()};
}
db.getDb().collection('summits').find(query, {projection: {'_id': false, 'photos': false, 'routes': false, 'links': false, 'resources': false}}).limit(limit).toArray((err, summits) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
res.json(summits);
});
});
app.get('/summits/:association/:code', (req, res) => {
db.getDb().collection('summits').findOne({code: req.params.association + '/' + req.params.code}, {projection: {'_id': false}}, (err, summit) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
if (!summit) {
res.status(404).end();
return;
}
let associationCode = summit.code.substring(0, summit.code.indexOf('/'));
db.getDb().collection('associations').findOne({code: associationCode}, (err, association) => {
if (association) {
summit.isoCode = association.isoCode;
summit.continent = association.continent;
}
res.json(summit);
});
});
});
// Dummy POST endpoint to help browser invalidate cache after uploading photos
app.post('/summits/:association/:code', (req, res) => {
res.cacheControl = {
noCache: true
};
res.status(204).end();
});
app.get('/associations/all', (req, res) => {
db.getDb().collection('associations').find({}, {projection: {'_id': false}}).toArray((err, associations) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
res.json(associations);
});
});
app.get('/associations/:association', (req, res) => {
db.getDb().collection('associations').findOne({code: req.params.association}, {projection: {'_id': false}}, (err, association) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
if (!association) {
res.status(404).end();
return;
}
res.json(association);
});
});
app.get('/regions/:association/:region', (req, res) => {
let region = req.params.association + '/' + req.params.region;
if (!region.match(/^[A-Z0-9]+\/[A-Z0-9]+$/)) {
res.status(400).end();
return;
}
db.getDb().collection('summits').find({code: {'$regex': '^' + region}}, {projection: {'_id': false, 'routes': false, 'links': false, 'resources': false}}).toArray((err, summits) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
summits.forEach(summit => {
if (summit.photos && summit.photos.length > 0) {
summit.hasPhotos = true;
}
delete summit.photos;
});
res.json(summits);
});
});
app.get('/activators/search', (req, res) => {
let skip = 0;
if (req.query.skip) {
skip = parseInt(req.query.skip);
}
let limit = 100;
if (req.query.limit) {
if (parseInt(req.query.limit) <= limit) {
limit = parseInt(req.query.limit);
}
}
let sortField = 'score';
let sortDirection = -1;
if (req.query.sort === 'callsign' || req.query.sort === 'points' || req.query.sort === 'bonusPoints' || req.query.sort === 'score' || req.query.sort === 'summits' || req.query.sort === 'avgPoints') {
sortField = req.query.sort;
}
if (req.query.sortDirection == 'desc') {
sortDirection = -1;
} else {
sortDirection = 1;
}
let sort = {};
sort[sortField] = sortDirection;
let query = {};
if (req.query.q !== undefined && req.query.q !== '') {
query = {callsign: {'$regex': req.query.q, '$options': 'i'}};
}
let cursor = db.getDb().collection('activators').find(query, {projection: {'_id': false}}).sort(sort);
cursor.count((err, count) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
cursor.skip(skip).limit(limit).toArray((err, activators) => {
res.json({activators, total: count});
});
});
});
app.get('/activators/:callsign', (req, res) => {
db.getDb().collection('activators').findOne({callsign: req.params.callsign}, {projection: {'_id': false}}, (err, activator) => {
if (err) {
console.error(err);
res.status(500).end();
return;
}
if (!activator) {
// Try alternative variations
db.getDb().collection('activators').findOne({callsign: { $in: utils.makeCallsignVariations(req.params.callsign) }}, {projection: {'_id': false}}, (err, activator) => {
if (activator) {
res.json(activator);
} else {
res.status(404).end();
}
});
return;
}
res.json(activator);
});
});
let geoLookup;
maxmind.open(config.geoip.path).then((lookup) => {
geoLookup = lookup;
});
app.get('/map_server', (req, res) => {
let mapServer = 'us';
let geo = geoLookup.get(req.ip);
if (geo.continent.code === 'AF' || geo.continent.code === 'EU') {
mapServer = 'eu';
}
res.json({mapServer});
});
app.get('/my_coordinates', (req, res) => {
let geo = geoLookup.get(req.ip);
if (!geo) {
res.json({});
} else {
res.json({latitude: geo.location.latitude, longitude: geo.location.longitude});
}
});
app.listen(config.http.port, config.http.host);

135
sotaspots.js 100644
Wyświetl plik

@ -0,0 +1,135 @@
const axios = require('axios');
const wsManager = require('./ws-manager');
const config = require('./config');
const db = require('./db');
const TreeMap = require("treemap-js");
class SotaSpotReceiver {
constructor() {
this.latestSpots = new TreeMap();
this.lastUpdate = null;
wsManager.on('connect', (ws) => {
let spots = []
this.latestSpots.each(spot => {
spots.push(spot)
});
wsManager.unicast({spots}, ws);
})
this.loadSpots();
setInterval(() => {
this.loadSpots();
}, config.sotaspots.updateInterval);
}
loadSpots() {
let numSpotsToLoad = config.sotaspots.periodicLoadSpots;
if (this.latestSpots.getLength() == 0) {
numSpotsToLoad = config.sotaspots.initialLoadSpots;
}
console.log(`load ${numSpotsToLoad} spots`);
axios.get(config.sotaspots.url + '/' + numSpotsToLoad + '/all')
.then(response => {
let minSpotId = undefined;
let currentSpotIds = new Set();
response.data.forEach(spot => {
spot.summit = {code: spot.associationCode.toUpperCase().trim() + '/' + spot.summitCode.toUpperCase().trim()};
spot.timeStamp = new Date(spot.timeStamp);
spot.activatorCallsign = spot.activatorCallsign.toUpperCase().replace(/[^A-Z0-9\/-]/g, '')
delete spot.associationCode;
delete spot.summitCode;
delete spot.summitDetails;
delete spot.highlightColor;
delete spot.activatorName;
if (spot.comments === '(null)') {
spot.comments = '';
}
this.updateSpot(spot);
currentSpotIds.add(spot.id);
if (minSpotId === undefined || spot.id < minSpotId) {
minSpotId = spot.id;
}
});
this.removeDeletedSpots(minSpotId, currentSpotIds);
this.removeExpiredSpots();
});
}
updateSpot(spot) {
// Check if we already have this spot in the list, and if it has changed
if (this.spotsAreEqual(this.latestSpots.get(spot.id), spot)) {
return;
}
// Spot is new or modified
console.log("New/modified spot id " + spot.id);
this.lookupSummit(spot.summit.code)
.then(summit => {
if (summit) {
spot.summit = summit;
}
this.lookupAssociation(spot.summit.code.substring(0, spot.summit.code.indexOf('/')))
.then(association => {
if (association) {
spot.summit.isoCode = association.isoCode;
spot.summit.continent = association.continent;
}
this.latestSpots.set(spot.id, spot);
wsManager.broadcast({spot});
})
})
}
deleteSpotById(spotId) {
console.log("Deleted spot id " + spotId);
if (this.latestSpots.get(spotId) !== undefined) {
this.latestSpots.remove(spotId);
wsManager.broadcast({deleteSpot: {id: spotId}});
}
}
removeDeletedSpots(minSpotId, currentSpotIds) {
// Consider all spots with ID >= minSpotId and not in currentSpotIds as deleted
this.latestSpots.each((spot, curId) => {
if (curId >= minSpotId && !currentSpotIds.has(curId)) {
this.deleteSpotById(curId);
}
});
}
removeExpiredSpots() {
let now = new Date();
while (this.latestSpots.getLength() > 0) {
let minKey = this.latestSpots.getMinKey();
if ((now - this.latestSpots.get(minKey).timeStamp) > config.sotaspots.maxSpotAge) {
console.log('Remove spot ID ' + minKey);
this.latestSpots.remove(minKey);
} else {
break;
}
}
}
lookupSummit(summitCode, callback) {
return db.getDb().collection('summits').findOne({code: summitCode}, {projection: {'_id': false, code: true, name: true, altitude: true, points: true, activationCount: true}});
}
lookupAssociation(associationCode, callback) {
return db.getDb().collection('associations').findOne({code: associationCode});
}
spotsAreEqual(spot1, spot2) {
if (spot1 === undefined || spot2 === undefined) {
return false;
}
return (spot1.id === spot2.id && spot1.comments === spot2.comments && spot1.callsign === spot2.callsign &&
spot1.summit.code === spot2.summit.code && spot1.activatorCallsign === spot2.activatorCallsign &&
spot1.frequency === spot2.frequency && spot1.mode === spot2.mode);
}
}
module.exports = SotaSpotReceiver;

74
spots.js 100644
Wyświetl plik

@ -0,0 +1,74 @@
const axios = require('axios');
const wsManager = require('./ws-manager');
const config = require('./config');
const db = require('./db');
const TreeMap = require("treemap-js");
const latestSpots = new TreeMap();
const maxSpots = 100;
const updateInterval = 30000;
let lastUpdate = null;
wsManager.on('connect', (ws) => {
let spots = []
latestSpots.each(spot => {
spots.push(spot)
});
wsManager.unicast({spots}, ws);
})
loadSpots();
setInterval(loadSpots, updateInterval);
function loadSpots() {
console.log('load spots');
axios.get('https://sota-api2.azurewebsites.net/api/spots/' + maxSpots + '/all')
.then(response => {
response.data.forEach(spot => {
spot.summit = {code: spot.associationCode + '/' + spot.summitCode};
delete spot.associationCode;
delete spot.summitCode;
delete spot.summitDetails;
delete spot.highlightColor;
if (spot.comments === '(null)') {
spot.comments = '';
}
updateSpot(spot);
});
});
}
function updateSpot(spot) {
// Check if we already have this spot in the list, and if it has changed
if (spotsAreEqual(latestSpots.get(spot.id), spot)) {
return;
}
// Spot is new or modified
console.log("New/modified spot id " + spot.id);
lookupSummit(spot.summit.code)
.then(summit => {
if (summit) {
spot.summit = summit;
}
latestSpots.set(spot.id, spot);
while (latestSpots.getLength() > maxSpots) {
latestSpots.remove(latestSpots.getMinKey());
}
wsManager.broadcast({spot});
})
}
function lookupSummit(summitCode, callback) {
return db.getDb().collection('summits').findOne({code: summitCode}, {projection: {'_id': false, code: true, name: true, altitude: true, points: true, activationCount: true}});
}
function spotsAreEqual(spot1, spot2) {
if (spot1 === undefined || spot2 === undefined) {
return false;
}
return (spot1.id === spot2.id && spot1.comments === spot2.comments && spot1.callsign === spot2.callsign &&
spot1.summit.code === spot2.summit.code && spot1.activatorCallsign === spot2.activatorCallsign &&
spot1.frequency === spot2.frequency && spot1.mode === spot2.mode);
}

68
summits.js 100644
Wyświetl plik

@ -0,0 +1,68 @@
const moment = require('moment');
const db = require('./db');
module.exports = {
lookupSummits: function(objects) {
// Get all summit refs so we can look them all up in one go
let summitCodes = new Set();
let associationCodes = new Set();
objects.forEach(obj => {
summitCodes.add(obj.summit.code);
associationCodes.add(obj.summit.code.substring(0, obj.summit.code.indexOf('/')));
});
return new Promise((resolve, reject) => {
db.getDb().collection('summits').find({code: {$in: [...summitCodes]}}, {projection: {'_id': false, code: true, name: true, altitude: true, points: true, coordinates: true, activationCount: true, validFrom: true, validTo: true, 'photos.author': true}})
.toArray((err, summits) => {
if (err) {
reject(err);
return;
}
let summitMap = {};
let now = moment();
summits.forEach(summit => {
if (now.isBefore(summit.validFrom) || now.isAfter(summit.validTo)) {
summit.invalid = true;
}
delete summit.validFrom;
delete summit.validTo;
if (summit.photos) {
let photoAuthors = new Set();
summit.photos.forEach(photo => {
photoAuthors.add(photo.author);
});
summit.photoAuthors = [...photoAuthors];
delete summit.photos;
}
summitMap[summit.code] = summit;
});
db.getDb().collection('associations').find({code: {$in: [...associationCodes]}}).toArray((err, associations) => {
if (err) {
reject(err);
return;
}
let associationMap = {};
associations.forEach(association => {
associationMap[association.code] = association;
});
objects.forEach(object => {
let association = object.summit.code.substring(0, object.summit.code.indexOf('/'));
if (summitMap[object.summit.code]) {
object.summit = summitMap[object.summit.code];
if (object.summit) {
object.summit.isoCode = associationMap[association].isoCode;
object.summit.continent = associationMap[association].continent;
}
}
});
resolve(objects);
});
});
});
}
}

Wyświetl plik

@ -0,0 +1,61 @@
const axios = require('axios');
const MongoClient = require('mongodb').MongoClient;
const config = require('../config');
const assert = require('assert');
const htmlparser = require('htmlparser2');
const client = new MongoClient(config.mongodb.url, {useUnifiedTopology: true});
client.connect(err => {
assert.equal(null, err);
importActivators(client.db(config.mongodb.dbName));
});
async function importActivators(db) {
let response = await axios.get('https://api-db.sota.org.uk/admin/activator_roll?associationID=-1')
// Weed out duplicate callsigns, keeping only the record with the higher number of points
let activators = new Map();
response.data.forEach(record => {
let callsign = record.Callsign.toUpperCase().trim().replace('/P', '');
let existingActivator = activators.get(callsign);
if (existingActivator === undefined || existingActivator.Points < record.Points) {
activators.set(callsign, record);
}
});
let lastUpdate = new Date();
let bulkWrites = [];
for (let record of activators.values()) {
let activator = {
callsign: record.Callsign.toUpperCase().trim().replace('/P', ''),
username: record.Username,
userId: record.UserID,
summits: record.Summits,
points: record.Points,
bonusPoints: record.BonusPoints,
score: record.totalPoints,
avgPoints: parseFloat(record.Average),
lastUpdate
};
bulkWrites.push({updateOne: {
filter: {callsign: activator.callsign},
update: { $set: activator},
upsert: true
}});
if (bulkWrites.length >= config.mongodb.batchSize) {
await db.collection('activators').bulkWrite(bulkWrites);
bulkWrites = [];
}
}
if (bulkWrites.length > 0) {
await db.collection('activators').bulkWrite(bulkWrites);
}
await db.collection('activators').deleteMany({lastUpdate: {$lt: lastUpdate}});
await client.close();
}

Wyświetl plik

@ -0,0 +1,33 @@
const MongoClient = require('mongodb').MongoClient
const config = require('../config')
const assert = require('assert')
const photos = require('../photos')
const db = require('../db')
let author = process.argv[2]
if (!author) {
console.error("usage: author file file ...")
process.exit(0)
}
db.waitDb(() => {
let imports = []
process.argv.slice(3).forEach(filename => {
imports.push(photos.importPhoto(filename, author))
})
// Run imports in series
return imports.reduce((promiseChain, currentImport) => {
return promiseChain.then(chainResults =>
currentImport.then(currentResult =>
[ ...chainResults, currentResult ]
)
)
}, Promise.resolve([])).then(photos => {
console.log(JSON.stringify(photos))
db.closeDb()
}).catch(err => {
console.error(err)
})
})

97
tools/isocodes.txt 100644
Wyświetl plik

@ -0,0 +1,97 @@
3Y,no,AF
4O,me,EU
4X,il,AS
5B,cy,AS
9A,hr,EU
9H,mt,EU
9M,my,AS
9V,sg,AS
A6,ae,AS
BV,tw,AS
CT,pt,EU
CU,pt,EU
CX,uy,SA
D,de,EU
E5,ck,OC
E7,ba,EU
EA,es,EU
EI,ie,EU
ES,ee,EU
FG,gp,NA
FH,yt,AF
FJ,fr,NA
FK,nc,OC
FM,mq,NA
FO,pf,OC
FP,fr,NA
FR,fr,AF
FT,fr,AF
F,fr,EU
GD,im,EU
GI,gb-nir,EU
GJ,je,EU
GM,gb-sct,EU
GU,gg,EU
GW,gb-wls,EU
G,gb-eng,EU
HA,hu,EU
HB0,li,EU
HB,ch,EU
HI,do,NA
HL,kr,AS
HR,hn,NA
I,it,EU
JA,jp,AS
JX,sj,EU
KP4,pr,NA
KH2,gu,OC
KH0,mp,OC
K,us,NA
LA,no,EU
LU,ar,SA
LX,lu,EU
LY,lt,EU
LZ,bg,EU
OD,lb,AS
OE,at,EU
OH,fi,EU
OK,cz,EU
OM,sk,EU
ON,be,EU
OY,fo,EU
OZ,dk,EU
PA,nl,EU
PP,br,SA
PQ,br,SA
PR,br,SA
PS,br,SA
PT,br,SA
PY,br,SA
R,ru,EU
S5,si,EU
S7,sc,AF
SM,se,EU
SP,pl,EU
SV,gr,EU
TF,is,EU
TI,cr,NA
TK,fr,EU
UT,ua,EU
V5,na,AF
VE,ca,NA
VK,au,OC
VO,ca,NA
VP8,fk,SA
VY,ca,EU
W,us,NA
XE,mx,NA
XF,mx,NA
YB,id,AS
YL,lv,EU
YO,ro,EU
YU,rs,EU
Z3,mk,EU
ZB2,gi,EU
ZD,gb,AF
ZL,nz,OC
ZS,za,AF

Wyświetl plik

@ -0,0 +1,32 @@
const config = require('../config')
const db = require('../db')
const sharp = require('sharp')
function regenerateThumbnails() {
// Fetch all summits with photos
db.getDb().collection('summits').find({'photos': {$exists: true, $ne: []}})
.each((err, summit) => {
if (summit) {
summit.photos.forEach(photo => {
regenerateThumbnailForPhoto(photo)
})
} else {
db.closeDb()
}
})
}
function regenerateThumbnailForPhoto(photo) {
console.dir(photo)
let sizeDescr = 'thumb'
let originalPath = config.photos.paths.original + '/' + photo.filename.substr(0, 2) + '/' + photo.filename
let outPath = config.photos.paths[sizeDescr] + '/' + photo.filename.substr(0, 2) + '/' + photo.filename
makeResized(originalPath, outPath, config.photos.sizes[sizeDescr].width, config.photos.sizes[sizeDescr].height)
}
function makeResized(src, dst, maxWidth, maxHeight) {
return sharp(src).rotate().resize({ height: maxHeight, width: maxWidth, fit: 'inside' }).toFile(dst)
}
db.waitDb(regenerateThumbnails)

Wyświetl plik

@ -0,0 +1,194 @@
const request = require('request');
const MongoClient = require('mongodb').MongoClient;
const config = require('../config');
const assert = require('assert');
const parse = require('csv-parse');
const fs = require('fs');
const removeDiacritics = require('diacritics').remove;
const client = new MongoClient(config.mongodb.url, {useUnifiedTopology: true});
client.connect(function (err) {
assert.equal(null, err);
processSummitList(client.db(config.mongodb.dbName));
});
function processSummitList(db) {
let associations = new Map();
let now = new Date();
let prefixToIsoCode = [];
parse(fs.readFileSync(__dirname + '/isocodes.txt'), function(err, isocodes) {
assert.equal(err, null);
prefixToIsoCode = isocodes;
});
request(config.summitListUrl, (error, response, body) => {
assert.equal(error, null);
body = body.substring(body.indexOf("\n")+1, body.length);
parse(body, {columns: true, relax_column_count: true}, function(err, summits) {
assert.equal(err, null);
if (summits.length < 100000) {
console.error("Bad number of summits, expecting more than 100000");
client.close();
return;
}
let bulkWrites = [];
for (let summit of summits) {
summit.SummitCode = summit.SummitCode.trim(); //anomaly GW/NW-003
summit.ValidFrom = dateToMongo(summit.ValidFrom);
summit.ValidTo = dateToMongo(summit.ValidTo, true);
if (summit.ActivationDate) {
summit.ActivationDate = dateToMongo(summit.ActivationDate);
} else {
summit.ActivationDate = null;
summit.ActivationCall = null;
}
bulkWrites.push({updateOne: {
filter: {code: summit.SummitCode},
update: { $set: {
code: summit.SummitCode,
name: summit.SummitName,
nameNd: removeDiacritics(summit.SummitName),
altitude: parseInt(summit.AltM),
points: parseInt(summit.Points),
bonusPoints: parseInt(summit.BonusPoints),
coordinates: {
longitude: Number(parseFloat(summit.Longitude).toFixed(5)),
latitude: Number(parseFloat(summit.Latitude).toFixed(5))
},
validFrom: summit.ValidFrom,
validTo: summit.ValidTo,
activationCount: parseInt(summit.ActivationCount),
activationCall: summit.ActivationCall,
activationDate: summit.ActivationDate
}},
upsert: true
}});
if (bulkWrites.length >= config.mongodb.batchSize) {
db.collection('summits').bulkWrite(bulkWrites);
bulkWrites = [];
}
let SummitAssociation = getAssociation(summit.SummitCode);
let SummitRegion = getRegion(summit.SummitCode);
let isValid = (summit.ValidFrom <= now && summit.ValidTo >= now);
let association = associations.get(SummitAssociation);
if (!association) {
let info = isoCodeForPrefix(SummitAssociation, prefixToIsoCode)
association = {code: SummitAssociation, name: summit.AssociationName, isoCode: info.isoCode, continent: info.continent, regions: new Map(), summitCount: 0};
associations.set(SummitAssociation, association);
}
let region = association.regions.get(SummitRegion);
if (!region) {
region = {code: SummitRegion, name: summit.RegionName, summitCount: 0};
association.regions.set(SummitRegion, region);
}
if (isValid) {
association.summitCount++;
region.summitCount++;
}
let lat = parseFloat(summit.Latitude);
let lon = parseFloat(summit.Longitude);
if (!region.bounds) {
region.bounds = [[lon, lat], [lon, lat]];
} else {
region.bounds[0][0] = Math.min(region.bounds[0][0], lon);
region.bounds[0][1] = Math.min(region.bounds[0][1], lat);
region.bounds[1][0] = Math.max(region.bounds[1][0], lon);
region.bounds[1][1] = Math.max(region.bounds[1][1], lat);
}
if (!association.bounds) {
association.bounds = [[lon, lat], [lon, lat]];
} else {
association.bounds[0][0] = Math.min(association.bounds[0][0], lon);
association.bounds[0][1] = Math.min(association.bounds[0][1], lat);
association.bounds[1][0] = Math.max(association.bounds[1][0], lon);
association.bounds[1][1] = Math.max(association.bounds[1][1], lat);
}
}
if (bulkWrites.length > 0) {
db.collection('summits').bulkWrite(bulkWrites);
}
for (let association of associations.values()) {
association.regions = [...association.regions.values()];
}
let associationCollection = db.collection('associations');
associationCollection.deleteMany({}, () => {
associationCollection.insertMany([...associations.values()], (err, r) => {
if (err)
console.error(err);
client.close();
});
});
});
});
}
function dateToMongo(date, endOfDay = false) {
let dateRegex = /^(\d\d)\/(\d\d)\/(\d\d\d\d)$/;
let dateRegex2 = /^(\d\d\d\d)-(\d\d)-(\d\d)/;
let matches = dateRegex.exec(date);
let matches2 = dateRegex2.exec(date);
if (matches) {
if (endOfDay) {
return new Date(Date.UTC(matches[3], matches[2]-1, matches[1], 23, 59, 59, 999));
} else {
return new Date(Date.UTC(matches[3], matches[2]-1, matches[1]));
}
} else if (matches2) {
if (endOfDay) {
return new Date(Date.UTC(matches2[1], matches2[2]-1, matches2[3], 23, 59, 59, 999));
} else {
return new Date(Date.UTC(matches2[1], matches2[2]-1, matches2[3]));
}
} else {
throw Error("Bad date " + date);
}
}
let summitRegex = /^(.+)\/(.+)-(\d+)$/;
function getAssociation(summitRef) {
let matches = summitRegex.exec(summitRef);
if (matches) {
return matches[1];
} else {
throw Error("Bad summit ref '" + summitRef + "'");
}
}
function getRegion(summitRef) {
let matches = summitRegex.exec(summitRef);
if (matches) {
return matches[2];
} else {
throw Error("Bad summit ref '" + summitRef + "'");
}
}
function isoCodeForPrefix(prefix, prefixToIsoCode) {
let isoCodeEnt = prefixToIsoCode.find(el => {
if (prefix.startsWith(el[0])) {
return true;
}
});
if (isoCodeEnt) {
return {isoCode: isoCodeEnt[1], continent: isoCodeEnt[2]};
} else {
console.error(`ISO code not found for prefix ${prefix}`);
return null;
}
}

Wyświetl plik

@ -0,0 +1,41 @@
const axios = require('axios');
const MongoClient = require('mongodb').MongoClient;
const config = require('../config');
const assert = require('assert');
const client = new MongoClient(config.mongodb.url, {useUnifiedTopology: true});
client.connect(function (err) {
assert.equal(null, err);
updateSotatrails(client.db(config.mongodb.dbName));
});
function updateSotatrails(db) {
axios.get(config.sotatrailsUrl)
.then(response => {
let bulkWrites = [];
response.data.forEach(report => {
let summitCode = report.association + '/' + report.region + '-' + report.code;
bulkWrites.push({updateOne: {
filter: {code: summitCode},
update: { $set: {
'resources.sotatrails': {
url: report.url,
details: report.details === 'true'
}
}},
upsert: false
}});
});
db.collection('summits').bulkWrite(bulkWrites, (err, r) => {
if (err)
console.error(err);
client.close();
});
})
.catch(error => {
console.error(error);
client.close();
})
}

73
tracks.js 100644
Wyświetl plik

@ -0,0 +1,73 @@
const togeojson = require('togeojson')
const fsPromises = require('fs').promises
const DOMParser = require('xmldom').DOMParser
const simplify = require('@turf/simplify')
const togpx = require('togpx')
const hasha = require('hasha')
const path = require('path')
const config = require('./config')
const db = require('./db')
module.exports = {
importTrack: async function(filename, author) {
// Hash input file to determine filename
let hash = await hasha.fromFile(filename, {algorithm: 'sha256'})
let hashFilename = hash.substr(0, 32)
let originalPath = config.tracks.paths.original + '/' + hashFilename.substr(0, 2) + '/' + hashFilename
await fsPromises.mkdir(path.dirname(originalPath), {recursive: true})
// Parse first to check if it's valid GPX/KML
let gpxData = await fsPromises.readFile(filename, 'utf-8')
let dom = new DOMParser().parseFromString(gpxData, 'text/xml')
if (!dom) {
throw new Error('Bad XML document')
}
let geojson
if (dom.documentElement.tagName === 'kml') {
geojson = togeojson.kml(dom)
originalPath += '.kml'
} else {
geojson = togeojson.gpx(dom)
originalPath += '.gpx'
}
if (geojson.type !== 'FeatureCollection') {
throw new Error('Expected feature collection')
}
if (geojson.features.length === 0) {
throw new Error('No features found')
}
await fsPromises.copyFile(filename, originalPath)
// Remove times, if present
geojson.features.forEach(feature => {
if (feature.type !== 'Feature') {
throw new Error('Expected feature')
}
if (feature.properties.coordTimes) {
delete feature.properties.coordTimes
}
})
let simplified = simplify(geojson, {tolerance: config.tracks.tolerance, highQuality: true})
let simpleGpx = togpx(simplified)
let outPath = config.tracks.paths.simple + '/' + hashFilename.substr(0, 2) + '/' + hashFilename + '.gpx'
await fsPromises.mkdir(path.dirname(outPath), {recursive: true})
await fsPromises.writeFile(outPath, simpleGpx)
db.getDb().collection('uploads').insertOne({
uploadDate: new Date(),
type: 'track',
filename: hashFilename + '.gpx',
author
})
return {
filename: hashFilename + '.gpx',
author
}
}
}

42
tracks_router.js 100644
Wyświetl plik

@ -0,0 +1,42 @@
const express = require('express')
const multer = require('multer')
const config = require('./config')
const tracks = require('./tracks')
const jwt = require('express-jwt')
const jwksRsa = require('jwks-rsa')
let upload = multer({dest: config.tracks.uploadPath})
let router = express.Router()
module.exports = router
let jwtCallback = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: config.sso.jwksUri
})
})
router.post('/upload', jwtCallback, upload.single('track'), (req, res) => {
res.cacheControl = {
noCache: true
}
if (!req.user.callsign) {
res.status(401).send('Missing callsign in SSO token').end()
return
}
if (req.file) {
tracks.importTrack(req.file.path, req.user.callsign)
.then(track => {
res.json(track)
})
.catch(err => {
console.error(err)
res.status(500).end()
})
}
})

17
utils.js 100644
Wyświetl plik

@ -0,0 +1,17 @@
module.exports = {
makeCallsignVariations(callsign) {
let matches = callsign.match(/^(2[DEIJMUW]|G[DIJMUW]?|M[DIJMUW]?)(\d[A-Z]{2,3})$/)
if (matches) {
if (matches[1].substring(0, 1) === '2') {
return ['2D' + matches[2], '2E' + matches[2], '2I' + matches[2], '2J' + matches[2], '2M' + matches[2], '2U' + matches[2], '2W' + matches[2]];
} else if (matches[1].substring(0, 1) === 'G') {
return ['GD' + matches[2], 'G' + matches[2], 'GI' + matches[2], 'GJ' + matches[2], 'GM' + matches[2], 'GU' + matches[2], 'GW' + matches[2]];
} else if (matches[1].substring(0, 1) === 'M') {
return ['MD' + matches[2], 'M' + matches[2], 'MI' + matches[2], 'MJ' + matches[2], 'MM' + matches[2], 'MU' + matches[2], 'MW' + matches[2]];
}
} else {
return [callsign];
}
}
};

82
ws-manager.js 100644
Wyświetl plik

@ -0,0 +1,82 @@
const express = require('express');
const EventEmitter = require('events');
const keyzipper = require('./keyzipper')
const PING_INTERVAL = 30000;
class WebSocketManager extends EventEmitter {
constructor() {
super();
this.webSocketClients = new Set();
this.router = express.Router();
this.router.ws('/', (ws, req) => {
console.log('WebSocket client connected');
ws.isAlive = true;
this.webSocketClients.add(ws);
console.log("Number of clients: " + this.webSocketClients.size);
this.emit('connect', ws);
ws.on('message', (data) => {
try {
let message = JSON.parse(data);
this.emit('message', ws, message);
} catch (e) {}
});
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('close', () => {
console.log("WebSocket closed");
clearInterval(ws.pingInterval);
this.webSocketClients.delete(ws);
console.log("Number of clients: " + this.webSocketClients.size);
});
ws.on('error', (error) => {
console.log("WebSocket error: " + error);
clearInterval(ws.pingInterval);
this.webSocketClients.delete(ws);
console.log("Number of clients: " + this.webSocketClients.size);
});
ws.pingInterval = setInterval(() => {
if (!ws.isAlive) {
console.log("WebSocket ping timeout");
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
}, PING_INTERVAL);
});
}
broadcast(message, filter) {
let str = JSON.stringify(keyzipper.compressKeys(message));
for (const ws of this.webSocketClients) {
if (filter && !filter(ws)) {
continue;
}
try {
ws.send(str);
} catch (e) {
console.error(e);
}
}
}
unicast(message, ws) {
ws.send(JSON.stringify(keyzipper.compressKeys(message)));
}
numberOfClients() {
return this.webSocketClients.size;
}
}
let wsManager = new WebSocketManager();
// This is a singleton for ease of use
module.exports = wsManager;