mirror of
https://codeberg.org/comaps/comaps
synced 2025-12-19 04:53:36 +00:00
Live location web app commit (REMOVE)
This commit is contained in:
10
webapp/.env.example
Normal file
10
webapp/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=./location_sharing.db
|
||||
|
||||
# Session Configuration
|
||||
SESSION_EXPIRY_HOURS=24
|
||||
CLEANUP_INTERVAL_MINUTES=60
|
||||
36
webapp/.gitignore
vendored
Normal file
36
webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# PM2
|
||||
.pm2/
|
||||
263
webapp/README.md
Normal file
263
webapp/README.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# CoMaps Live Location Sharing Server
|
||||
|
||||
A Node.js server for real-time, end-to-end encrypted location sharing with web client viewer.
|
||||
|
||||
## Features
|
||||
|
||||
- **End-to-end encryption**: Location data is encrypted on the device using AES-GCM-256
|
||||
- **Real-time updates**: Poll-based location updates every 5 seconds
|
||||
- **Session management**: Automatic cleanup of expired sessions
|
||||
- **Web viewer**: Interactive map viewer with Leaflet.js
|
||||
- **Navigation support**: Display ETA, distance, and destination when navigating
|
||||
- **Battery monitoring**: Shows battery level and warnings
|
||||
- **Location history**: Trail visualization with polyline
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd webapp
|
||||
npm install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
```
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
DATABASE_PATH=./location_sharing.db
|
||||
SESSION_EXPIRY_HOURS=24
|
||||
CLEANUP_INTERVAL_MINUTES=60
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
Development mode (with auto-reload):
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Production mode:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3000`
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Create/Reactivate Session
|
||||
|
||||
```
|
||||
POST /api/sessions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"sessionId": "uuid-string"
|
||||
}
|
||||
```
|
||||
|
||||
### Store Location Update
|
||||
|
||||
```
|
||||
POST /api/sessions/:sessionId/location
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"encryptedPayload": "{\"iv\":\"...\",\"ciphertext\":\"...\",\"authTag\":\"...\"}"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Session Info
|
||||
|
||||
```
|
||||
GET /api/sessions/:sessionId
|
||||
```
|
||||
|
||||
### Get Latest Location
|
||||
|
||||
```
|
||||
GET /api/sessions/:sessionId/location/latest
|
||||
```
|
||||
|
||||
### Get Location History
|
||||
|
||||
```
|
||||
GET /api/sessions/:sessionId/location/history?limit=100
|
||||
```
|
||||
|
||||
### Stop Session
|
||||
|
||||
```
|
||||
DELETE /api/sessions/:sessionId
|
||||
```
|
||||
|
||||
### Server Statistics
|
||||
|
||||
```
|
||||
GET /api/stats
|
||||
```
|
||||
|
||||
## URL Format
|
||||
|
||||
Share URLs are formatted as:
|
||||
```
|
||||
https://your-server.com/live/{base64url(sessionId:encryptionKey)}
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
https://live.organicmaps.app/live/MjAyM...c3NrZXk
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### sessions
|
||||
- `session_id` (TEXT, PRIMARY KEY)
|
||||
- `created_at` (INTEGER)
|
||||
- `last_update` (INTEGER)
|
||||
- `expires_at` (INTEGER)
|
||||
- `is_active` (INTEGER)
|
||||
|
||||
### location_updates
|
||||
- `id` (INTEGER, PRIMARY KEY AUTOINCREMENT)
|
||||
- `session_id` (TEXT, FOREIGN KEY)
|
||||
- `encrypted_payload` (TEXT)
|
||||
- `timestamp` (INTEGER)
|
||||
|
||||
## Encryption
|
||||
|
||||
Location data is encrypted using AES-GCM-256 with:
|
||||
- Random 96-bit IV
|
||||
- 128-bit authentication tag
|
||||
- Base64-encoded encryption key (256-bit)
|
||||
|
||||
Encrypted payload format (JSON):
|
||||
```json
|
||||
{
|
||||
"iv": "base64-encoded-iv",
|
||||
"ciphertext": "base64-encoded-ciphertext",
|
||||
"authTag": "base64-encoded-auth-tag"
|
||||
}
|
||||
```
|
||||
|
||||
Decrypted payload format (JSON):
|
||||
```json
|
||||
{
|
||||
"timestamp": 1234567890,
|
||||
"lat": 37.7749,
|
||||
"lon": -122.4194,
|
||||
"accuracy": 10.5,
|
||||
"speed": 5.2,
|
||||
"bearing": 45.0,
|
||||
"mode": "navigation",
|
||||
"eta": 1234567900,
|
||||
"distanceRemaining": 5000,
|
||||
"destinationName": "Home",
|
||||
"batteryLevel": 75
|
||||
}
|
||||
```
|
||||
|
||||
## Web Viewer
|
||||
|
||||
The web viewer is accessible at `/live/{encodedCredentials}` and provides:
|
||||
|
||||
- Interactive map with Leaflet.js and OpenStreetMap tiles
|
||||
- Real-time location marker with accuracy circle
|
||||
- Location trail visualization
|
||||
- Info panel showing:
|
||||
- Status (live/inactive)
|
||||
- Coordinates
|
||||
- Accuracy
|
||||
- Speed (if available)
|
||||
- Navigation info (ETA, distance, destination)
|
||||
- Battery level
|
||||
- Automatic polling every 5 seconds
|
||||
- Responsive design (mobile-friendly)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS Required**: Always use HTTPS in production to protect the share URLs
|
||||
2. **Encryption Keys**: Never log or expose encryption keys server-side
|
||||
3. **Session Expiry**: Sessions automatically expire after 24 hours (configurable)
|
||||
4. **Rate Limiting**: Consider adding rate limiting for production deployments
|
||||
5. **CORS**: Configure CORS appropriately for your deployment
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Using PM2
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
pm2 start src/server.js --name location-sharing
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
### Using Docker
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --production
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/server.js"]
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these in production:
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DATABASE_PATH=/data/location_sharing.db
|
||||
SESSION_EXPIRY_HOURS=24
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Database Cleanup
|
||||
|
||||
The server automatically cleans up:
|
||||
- Expired sessions (older than expiry time)
|
||||
- Inactive sessions (older than 1 week)
|
||||
- Old location updates (older than 1 week)
|
||||
|
||||
Cleanup runs every 60 minutes by default (configurable).
|
||||
|
||||
### Manual Cleanup
|
||||
|
||||
```bash
|
||||
# Stop the server
|
||||
# Delete the database file
|
||||
rm location_sharing.db
|
||||
# Restart the server (will recreate schema)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Location not updating
|
||||
- Check that the session is active
|
||||
- Verify the encrypted payload format
|
||||
- Check server logs for decryption errors
|
||||
- Ensure the encryption key matches
|
||||
|
||||
### Web viewer shows error
|
||||
- Verify the share URL is correct
|
||||
- Check that the session exists
|
||||
- Ensure at least one location update has been sent
|
||||
- Check browser console for decryption errors
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1655
webapp/package-lock.json
generated
Normal file
1655
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
webapp/package.json
Normal file
22
webapp/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "location-sharing-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Live location sharing server for CoMaps",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"keywords": ["location", "sharing", "maps"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
472
webapp/public/app.js
Normal file
472
webapp/public/app.js
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Live Location Viewer Application
|
||||
*/
|
||||
|
||||
class LocationViewer {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.marker = null;
|
||||
this.polyline = null;
|
||||
this.sessionId = null;
|
||||
this.encryptionKey = null;
|
||||
this.updateInterval = null;
|
||||
this.locationHistory = [];
|
||||
this.currentLocation = null;
|
||||
this.lastUpdateTimestamp = null;
|
||||
this.lastServerTimestamp = null;
|
||||
this.isActive = false;
|
||||
this.isFollowing = true;
|
||||
this.userMovedMap = false;
|
||||
this.lastNavigationMode = null;
|
||||
this.isInitialLoad = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// Parse credentials from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const encodedCredentials = pathParts[pathParts.length - 1];
|
||||
|
||||
const credentials = LocationCrypto.parseCredentials(encodedCredentials);
|
||||
if (!credentials) {
|
||||
this.showError('Invalid share link');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionId = credentials.sessionId;
|
||||
this.encryptionKey = credentials.encryptionKey;
|
||||
|
||||
// Initialize map
|
||||
this.initMap();
|
||||
|
||||
// Load location history first
|
||||
await this.loadLocationHistory();
|
||||
|
||||
// Load latest location
|
||||
await this.loadLocation();
|
||||
|
||||
// Check session status
|
||||
await this.checkSessionStatus();
|
||||
|
||||
// Initial load is complete - now track user interactions
|
||||
setTimeout(() => {
|
||||
this.isInitialLoad = false;
|
||||
}, 1000);
|
||||
|
||||
// Start polling for updates
|
||||
this.startPolling();
|
||||
|
||||
// Show info panel
|
||||
document.getElementById('infoPanel').style.display = 'block';
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
} catch (err) {
|
||||
console.error('Initialization error:', err);
|
||||
this.showError(err.message || 'Failed to initialize viewer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Leaflet map
|
||||
*/
|
||||
initMap() {
|
||||
this.map = L.map('map').setView([0, 0], 2);
|
||||
|
||||
// Use CartoDB Positron - free minimalist black and white tileset
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 20
|
||||
}).addTo(this.map);
|
||||
|
||||
// Initialize polyline for location history
|
||||
this.polyline = L.polyline([], {
|
||||
color: '#2196f3',
|
||||
weight: 3,
|
||||
opacity: 0.7
|
||||
}).addTo(this.map);
|
||||
|
||||
// Detect user map interaction (manual drag/zoom)
|
||||
this.map.on('dragstart', () => {
|
||||
if (!this.isInitialLoad) {
|
||||
this.userMovedMap = true;
|
||||
this.isFollowing = false;
|
||||
this.showFollowButton();
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('zoomstart', () => {
|
||||
if (!this.isInitialLoad) {
|
||||
this.userMovedMap = true;
|
||||
this.isFollowing = false;
|
||||
this.showFollowButton();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up follow button
|
||||
const followButton = document.getElementById('followButton');
|
||||
followButton.addEventListener('click', () => {
|
||||
this.isFollowing = true;
|
||||
this.userMovedMap = false;
|
||||
this.hideFollowButton();
|
||||
if (this.currentLocation) {
|
||||
this.map.setView([this.currentLocation.lat, this.currentLocation.lon], 15);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show follow button
|
||||
*/
|
||||
showFollowButton() {
|
||||
document.getElementById('followButton').classList.add('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide follow button
|
||||
*/
|
||||
hideFollowButton() {
|
||||
document.getElementById('followButton').classList.remove('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load location history from server
|
||||
*/
|
||||
async loadLocationHistory(sinceTimestamp = null) {
|
||||
try {
|
||||
let url = `/api/sessions/${this.sessionId}/location/history?limit=100`;
|
||||
if (sinceTimestamp) {
|
||||
url += `&since=${sinceTimestamp}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return; // History might not be available yet
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newLocations = [];
|
||||
|
||||
for (const update of data.updates) {
|
||||
try {
|
||||
const decrypted = await LocationCrypto.decryptPayload(
|
||||
update.encryptedPayload,
|
||||
this.encryptionKey
|
||||
);
|
||||
|
||||
// Check if this location is not already in history
|
||||
const isDuplicate = this.locationHistory.some(loc =>
|
||||
loc.lat === decrypted.lat &&
|
||||
loc.lon === decrypted.lon &&
|
||||
loc.timestamp === decrypted.timestamp
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
newLocations.push({
|
||||
lat: decrypted.lat,
|
||||
lon: decrypted.lon,
|
||||
timestamp: decrypted.timestamp
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt historical location:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new locations to history sorted by timestamp
|
||||
this.locationHistory.push(...newLocations);
|
||||
this.locationHistory.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Update polyline with historical data
|
||||
if (this.locationHistory.length > 0) {
|
||||
this.updatePolyline();
|
||||
|
||||
// Center map on latest location only on first load and if following
|
||||
if (!sinceTimestamp && this.isFollowing) {
|
||||
const latestLoc = this.locationHistory[this.locationHistory.length - 1];
|
||||
this.map.setView([latestLoc.lat, latestLoc.lon], 15);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load history error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check session status
|
||||
*/
|
||||
async checkSessionStatus() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/session/${this.sessionId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
this.isActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.isActive = data.isActive;
|
||||
} catch (err) {
|
||||
console.error('Check session status error:', err);
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load location from server
|
||||
*/
|
||||
async loadLocation() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/location/${this.sessionId}/latest`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('No location data available yet. Waiting for updates...');
|
||||
}
|
||||
throw new Error('Failed to load location');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newTimestamp = data.timestamp;
|
||||
|
||||
// Check for gap in updates (more than 30 seconds)
|
||||
if (this.lastServerTimestamp && (newTimestamp - this.lastServerTimestamp) > 30000) {
|
||||
console.log('Detected gap in updates, loading missing history...');
|
||||
await this.loadLocationHistory(this.lastServerTimestamp);
|
||||
}
|
||||
|
||||
this.lastUpdateTimestamp = data.timestamp;
|
||||
this.lastServerTimestamp = newTimestamp;
|
||||
await this.processLocationUpdate(data);
|
||||
|
||||
// Update session status on each location update
|
||||
await this.checkSessionStatus();
|
||||
} catch (err) {
|
||||
console.error('Load location error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an encrypted location update
|
||||
*/
|
||||
async processLocationUpdate(data) {
|
||||
try {
|
||||
// Decrypt the payload
|
||||
const decrypted = await LocationCrypto.decryptPayload(
|
||||
data.encryptedPayload,
|
||||
this.encryptionKey
|
||||
);
|
||||
|
||||
this.currentLocation = decrypted;
|
||||
|
||||
// Update map
|
||||
this.updateMap(decrypted);
|
||||
|
||||
// Update info panel
|
||||
this.updateInfoPanel(decrypted, data.timestamp);
|
||||
|
||||
// Add to history only if it's not already there (avoid duplicates)
|
||||
const isDuplicate = this.locationHistory.some(loc =>
|
||||
loc.lat === decrypted.lat &&
|
||||
loc.lon === decrypted.lon &&
|
||||
loc.timestamp === decrypted.timestamp
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
this.locationHistory.push({
|
||||
lat: decrypted.lat,
|
||||
lon: decrypted.lon,
|
||||
timestamp: decrypted.timestamp
|
||||
});
|
||||
|
||||
// Update polyline only when we add a new point
|
||||
this.updatePolyline();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to process location update:', err);
|
||||
// Don't throw - just skip this update
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map with new location
|
||||
*/
|
||||
updateMap(location) {
|
||||
const latLng = [location.lat, location.lon];
|
||||
|
||||
if (!this.marker) {
|
||||
// Create marker
|
||||
const icon = L.divIcon({
|
||||
className: 'location-marker',
|
||||
html: '<div style="background: #2196f3; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13]
|
||||
});
|
||||
|
||||
this.marker = L.marker(latLng, { icon }).addTo(this.map);
|
||||
if (this.isFollowing) {
|
||||
this.map.setView(latLng, 15);
|
||||
}
|
||||
} else {
|
||||
// Update marker position
|
||||
this.marker.setLatLng(latLng);
|
||||
}
|
||||
|
||||
// Add accuracy circle if available
|
||||
if (location.accuracy) {
|
||||
if (this.accuracyCircle) {
|
||||
this.accuracyCircle.setLatLng(latLng);
|
||||
this.accuracyCircle.setRadius(location.accuracy);
|
||||
} else {
|
||||
this.accuracyCircle = L.circle(latLng, {
|
||||
radius: location.accuracy,
|
||||
color: '#2196f3',
|
||||
fillColor: '#2196f3',
|
||||
fillOpacity: 0.1,
|
||||
weight: 1
|
||||
}).addTo(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
// Pan to new location only if following
|
||||
if (this.isFollowing) {
|
||||
this.map.panTo(latLng);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update polyline with location history
|
||||
*/
|
||||
updatePolyline() {
|
||||
const points = this.locationHistory.map(loc => [loc.lat, loc.lon]);
|
||||
this.polyline.setLatLngs(points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update info panel
|
||||
*/
|
||||
updateInfoPanel(location, serverTimestamp) {
|
||||
// Determine if session is truly live (updated within last 30 seconds)
|
||||
const now = Date.now();
|
||||
const timeSinceUpdate = now - serverTimestamp;
|
||||
const isLive = timeSinceUpdate < 30000;
|
||||
|
||||
// Only show status if it's inactive AND there's old data OR session is explicitly closed
|
||||
let statusHtml = '';
|
||||
if (!isLive && timeSinceUpdate > 60000) {
|
||||
statusHtml = `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="status inactive">Inactive</span>
|
||||
</div>
|
||||
`;
|
||||
} else if (isLive) {
|
||||
statusHtml = `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="status active">Live</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show speed if available
|
||||
if (location.speed !== undefined && location.speed >= 0) {
|
||||
statusHtml += `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Speed</span>
|
||||
<span class="info-value">${(location.speed * 3.6).toFixed(1)} km/h</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('statusInfo').innerHTML = statusHtml;
|
||||
|
||||
// Navigation info - only show if currently in navigation mode
|
||||
if (location.mode === 'navigation' && location.eta) {
|
||||
this.lastNavigationMode = true;
|
||||
const etaDate = new Date(location.eta * 1000);
|
||||
const navHtml = `
|
||||
<div class="nav-info">
|
||||
<div class="nav-info-label">NAVIGATION ACTIVE</div>
|
||||
${location.destinationName ? `<div class="nav-info-value">To: ${location.destinationName}</div>` : ''}
|
||||
<div class="nav-info-value">ETA: ${etaDate.toLocaleTimeString()}</div>
|
||||
${location.distanceRemaining ? `<div class="nav-info-value">Distance: ${(location.distanceRemaining / 1000).toFixed(1)} km</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('navInfo').innerHTML = navHtml;
|
||||
} else if (this.lastNavigationMode && location.mode !== 'navigation') {
|
||||
// Navigation just ended - clear the display
|
||||
this.lastNavigationMode = false;
|
||||
document.getElementById('navInfo').innerHTML = '';
|
||||
} else if (!this.lastNavigationMode) {
|
||||
// Never was in navigation or already cleared
|
||||
document.getElementById('navInfo').innerHTML = '';
|
||||
}
|
||||
|
||||
// Battery warning
|
||||
if (location.batteryLevel !== undefined && location.batteryLevel < 20) {
|
||||
document.getElementById('batteryWarning').innerHTML = `
|
||||
<div class="battery-warning">
|
||||
⚠️ Low battery: ${location.batteryLevel}%
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('batteryWarning').innerHTML = '';
|
||||
}
|
||||
|
||||
// Update time
|
||||
const updateDate = new Date(serverTimestamp);
|
||||
document.getElementById('updateTime').innerHTML = `
|
||||
Last update: ${updateDate.toLocaleTimeString()}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for location updates
|
||||
*/
|
||||
startPolling() {
|
||||
// Poll every 5 seconds
|
||||
this.updateInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.loadLocation();
|
||||
} catch (err) {
|
||||
console.error('Polling error:', err);
|
||||
// Continue polling even if there's an error
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling
|
||||
*/
|
||||
stopPolling() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(message) {
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
document.getElementById('errorMessage').style.display = 'block';
|
||||
document.getElementById('errorText').textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const viewer = new LocationViewer();
|
||||
viewer.init();
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
viewer.stopPolling();
|
||||
});
|
||||
});
|
||||
109
webapp/public/crypto.js
Normal file
109
webapp/public/crypto.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Crypto utilities for decrypting location data
|
||||
* Uses Web Crypto API (AES-GCM-256)
|
||||
*/
|
||||
|
||||
class LocationCrypto {
|
||||
/**
|
||||
* Decode base64url to base64
|
||||
*/
|
||||
static base64UrlToBase64(base64url) {
|
||||
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the encoded credentials from URL
|
||||
* Format: sessionId:encryptionKey (base64url encoded)
|
||||
*/
|
||||
static parseCredentials(encodedCredentials) {
|
||||
try {
|
||||
const base64 = this.base64UrlToBase64(encodedCredentials);
|
||||
const decoded = atob(base64);
|
||||
const [sessionId, encryptionKey] = decoded.split(':');
|
||||
|
||||
if (!sessionId || !encryptionKey) {
|
||||
throw new Error('Invalid credentials format');
|
||||
}
|
||||
|
||||
return { sessionId, encryptionKey };
|
||||
} catch (err) {
|
||||
console.error('Failed to parse credentials:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 encryption key to CryptoKey
|
||||
*/
|
||||
static async importKey(base64Key) {
|
||||
try {
|
||||
const keyData = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));
|
||||
|
||||
return await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to import key:', err);
|
||||
throw new Error('Invalid encryption key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the encrypted payload
|
||||
* Payload format (JSON): { iv: base64, ciphertext: base64, authTag: base64 }
|
||||
*/
|
||||
static async decryptPayload(encryptedPayloadJson, encryptionKey) {
|
||||
try {
|
||||
// Parse the encrypted payload
|
||||
const payload = JSON.parse(encryptedPayloadJson);
|
||||
const { iv, ciphertext, authTag } = payload;
|
||||
|
||||
if (!iv || !ciphertext || !authTag) {
|
||||
throw new Error('Invalid payload format');
|
||||
}
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await this.importKey(encryptionKey);
|
||||
|
||||
// Decode base64 components
|
||||
const ivBytes = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
|
||||
const ciphertextBytes = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0));
|
||||
const authTagBytes = Uint8Array.from(atob(authTag), c => c.charCodeAt(0));
|
||||
|
||||
// Combine ciphertext and auth tag (GCM mode requires them together)
|
||||
const combined = new Uint8Array(ciphertextBytes.length + authTagBytes.length);
|
||||
combined.set(ciphertextBytes, 0);
|
||||
combined.set(authTagBytes, ciphertextBytes.length);
|
||||
|
||||
// Decrypt
|
||||
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: ivBytes,
|
||||
tagLength: 128 // 16 bytes = 128 bits
|
||||
},
|
||||
cryptoKey,
|
||||
combined
|
||||
);
|
||||
|
||||
// Convert to string and parse JSON
|
||||
const decoder = new TextDecoder();
|
||||
const decryptedText = decoder.decode(decryptedBuffer);
|
||||
return JSON.parse(decryptedText);
|
||||
} catch (err) {
|
||||
console.error('Decryption failed:', err);
|
||||
throw new Error('Failed to decrypt location data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in app.js
|
||||
window.LocationCrypto = LocationCrypto;
|
||||
214
webapp/public/index.html
Normal file
214
webapp/public/index.html
Normal file
@@ -0,0 +1,214 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CoMaps Live Location Sharing</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 48px;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
text-align: left;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.api-docs {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
text-align: left;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.api-docs h2 {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.method.get {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.method.post {
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
.method.delete {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>CoMaps Live Location Sharing</h1>
|
||||
<p>
|
||||
End-to-end encrypted real-time location sharing server. Share your location securely
|
||||
with friends and family while navigating.
|
||||
</p>
|
||||
|
||||
<div class="feature-list">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<div class="feature-text">
|
||||
<strong>End-to-end encrypted</strong><br>
|
||||
Location data is encrypted on device, only you and recipients can decrypt it
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">🗺️</div>
|
||||
<div class="feature-text">
|
||||
<strong>Real-time updates</strong><br>
|
||||
See live location updates with navigation info and ETA
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">📱</div>
|
||||
<div class="feature-text">
|
||||
<strong>Works everywhere</strong><br>
|
||||
Compatible with CoMaps mobile apps and any web browser
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-docs">
|
||||
<h2>API Endpoints</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
/api/sessions
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
/api/sessions/:sessionId/location
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
/api/sessions/:sessionId/location/latest
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
/api/sessions/:sessionId/location/history
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method delete">DELETE</span>
|
||||
/api/sessions/:sessionId
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats">
|
||||
Loading server statistics...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load server stats
|
||||
fetch('/api/stats')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
document.getElementById('stats').innerHTML = `
|
||||
Active sessions: <strong>${data.activeSessions}</strong> |
|
||||
Total updates: <strong>${data.totalLocationUpdates}</strong>
|
||||
`;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('stats').innerHTML = 'Server statistics unavailable';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
252
webapp/public/viewer.html
Normal file
252
webapp/public/viewer.html
Normal file
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Location Sharing - CoMaps</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
#map {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 20px;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.info-panel h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status.inactive {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
text-align: center;
|
||||
z-index: 2000;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-message h2 {
|
||||
color: #d32f2f;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.nav-info {
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav-info-label {
|
||||
font-size: 12px;
|
||||
color: #1565c0;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.battery-warning {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ff9800;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.update-time {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.follow-button {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
transition: all 0.2s;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.follow-button:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.follow-button.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.follow-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: #2196f3;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-panel {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
left: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.follow-button {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<div class="info-panel" id="infoPanel" style="display: none;">
|
||||
<div id="statusInfo"></div>
|
||||
<div id="navInfo"></div>
|
||||
<div id="batteryWarning"></div>
|
||||
<div class="update-time" id="updateTime"></div>
|
||||
</div>
|
||||
|
||||
<button class="follow-button" id="followButton" title="Follow location">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46 4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="loading-spinner" id="loadingSpinner">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="errorMessage" style="display: none;">
|
||||
<h2>Unable to Load Location</h2>
|
||||
<p id="errorText"></p>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/crypto.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
240
webapp/src/database.js
Normal file
240
webapp/src/database.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
class LocationDatabase {
|
||||
constructor(dbPath) {
|
||||
this.db = new Database(dbPath || path.join(__dirname, '..', 'location_sharing.db'));
|
||||
this.initSchema();
|
||||
this.setupCleanup();
|
||||
}
|
||||
|
||||
initSchema() {
|
||||
// Create sessions table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_update INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_active
|
||||
ON sessions(is_active, expires_at);
|
||||
`);
|
||||
|
||||
// Create location_updates table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS location_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
encrypted_payload TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_session
|
||||
ON location_updates(session_id, timestamp DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
createSession(sessionId, expiryHours = 24) {
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (expiryHours * 60 * 60 * 1000);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO sessions (session_id, created_at, last_update, expires_at, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
`);
|
||||
|
||||
try {
|
||||
stmt.run(sessionId, now, now, expiresAt);
|
||||
return { success: true, sessionId };
|
||||
} catch (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT') {
|
||||
// Session already exists, update it
|
||||
return this.reactivateSession(sessionId, expiryHours);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate an existing session
|
||||
*/
|
||||
reactivateSession(sessionId, expiryHours = 24) {
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (expiryHours * 60 * 60 * 1000);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE sessions
|
||||
SET is_active = 1, last_update = ?, expires_at = ?
|
||||
WHERE session_id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(now, expiresAt, sessionId);
|
||||
return {
|
||||
success: result.changes > 0,
|
||||
sessionId,
|
||||
reactivated: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a location update
|
||||
*/
|
||||
storeLocationUpdate(sessionId, encryptedPayload) {
|
||||
const now = Date.now();
|
||||
|
||||
// First, check if session exists and is active
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session || !session.is_active || session.expires_at < now) {
|
||||
return { success: false, error: 'Session not found or expired' };
|
||||
}
|
||||
|
||||
// Update session last_update timestamp
|
||||
const updateSession = this.db.prepare(`
|
||||
UPDATE sessions SET last_update = ? WHERE session_id = ?
|
||||
`);
|
||||
updateSession.run(now, sessionId);
|
||||
|
||||
// Insert location update
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO location_updates (session_id, encrypted_payload, timestamp)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
try {
|
||||
const result = stmt.run(sessionId, encryptedPayload, now);
|
||||
return {
|
||||
success: true,
|
||||
updateId: result.lastInsertRowid
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session info
|
||||
*/
|
||||
getSession(sessionId) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM sessions WHERE session_id = ?
|
||||
`);
|
||||
return stmt.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest location update for a session
|
||||
*/
|
||||
getLatestLocation(sessionId) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT encrypted_payload, timestamp
|
||||
FROM location_updates
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
return stmt.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location history for a session
|
||||
*/
|
||||
getLocationHistory(sessionId, limit = 100) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT encrypted_payload, timestamp
|
||||
FROM location_updates
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(sessionId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a session
|
||||
*/
|
||||
stopSession(sessionId) {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE sessions SET is_active = 0 WHERE session_id = ?
|
||||
`);
|
||||
const result = stmt.run(sessionId);
|
||||
return { success: result.changes > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions and old location updates
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
const oneWeekAgo = now - (7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Mark expired sessions as inactive
|
||||
const expireStmt = this.db.prepare(`
|
||||
UPDATE sessions
|
||||
SET is_active = 0
|
||||
WHERE expires_at < ? AND is_active = 1
|
||||
`);
|
||||
const expired = expireStmt.run(now);
|
||||
|
||||
// Delete old inactive sessions (older than 1 week)
|
||||
const deleteSessionsStmt = this.db.prepare(`
|
||||
DELETE FROM sessions
|
||||
WHERE is_active = 0 AND expires_at < ?
|
||||
`);
|
||||
const deletedSessions = deleteSessionsStmt.run(oneWeekAgo);
|
||||
|
||||
// Delete orphaned location updates (sessions deleted by CASCADE)
|
||||
// This is automatic due to CASCADE, but we can also delete old updates
|
||||
const deleteUpdatesStmt = this.db.prepare(`
|
||||
DELETE FROM location_updates
|
||||
WHERE timestamp < ?
|
||||
`);
|
||||
const deletedUpdates = deleteUpdatesStmt.run(oneWeekAgo);
|
||||
|
||||
return {
|
||||
expiredSessions: expired.changes,
|
||||
deletedSessions: deletedSessions.changes,
|
||||
deletedUpdates: deletedUpdates.changes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup automatic cleanup
|
||||
*/
|
||||
setupCleanup(intervalMinutes = 60) {
|
||||
setInterval(() => {
|
||||
const result = this.cleanup();
|
||||
console.log('[DB Cleanup]', new Date().toISOString(), result);
|
||||
}, intervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
getStats() {
|
||||
const sessionCount = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM sessions WHERE is_active = 1
|
||||
`).get();
|
||||
|
||||
const totalUpdates = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM location_updates
|
||||
`).get();
|
||||
|
||||
return {
|
||||
activeSessions: sessionCount.count,
|
||||
totalLocationUpdates: totalUpdates.count
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocationDatabase;
|
||||
274
webapp/src/server.js
Normal file
274
webapp/src/server.js
Normal file
@@ -0,0 +1,274 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const LocationDatabase = require('./database');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Initialize database
|
||||
const db = new LocationDatabase(process.env.DATABASE_PATH);
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* API: Create or reactivate a session
|
||||
* POST /api/v1/session (Android app format)
|
||||
* POST /api/sessions (legacy format)
|
||||
* Body: { sessionId: string }
|
||||
*/
|
||||
const createSessionHandler = (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const expiryHours = parseInt(process.env.SESSION_EXPIRY_HOURS) || 24;
|
||||
const result = db.createSession(sessionId, expiryHours);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Error creating session:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/api/v1/session', createSessionHandler);
|
||||
app.post('/api/sessions', createSessionHandler);
|
||||
|
||||
/**
|
||||
* API: Store a location update
|
||||
* POST /api/v1/location/:sessionId (Android app format - encrypted payload is the entire body)
|
||||
* Body: encrypted payload JSON directly
|
||||
*/
|
||||
app.post('/api/v1/location/:sessionId', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
// Android sends the encrypted payload JSON directly as the request body
|
||||
const encryptedPayload = JSON.stringify(req.body);
|
||||
|
||||
if (!encryptedPayload || encryptedPayload === '{}') {
|
||||
return res.status(400).json({ error: 'Encrypted payload is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = db.storeLocationUpdate(sessionId, encryptedPayload);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Error storing location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* API: Store a location update (legacy format)
|
||||
* POST /api/sessions/:sessionId/location
|
||||
* Body: { encryptedPayload: string }
|
||||
*/
|
||||
app.post('/api/sessions/:sessionId/location', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { encryptedPayload } = req.body;
|
||||
|
||||
if (!encryptedPayload) {
|
||||
return res.status(400).json({ error: 'encryptedPayload is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = db.storeLocationUpdate(sessionId, encryptedPayload);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Error storing location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* API: Get session info
|
||||
* GET /api/v1/session/:sessionId (Android app format)
|
||||
* GET /api/sessions/:sessionId (legacy format)
|
||||
*/
|
||||
const getSessionHandler = (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
const session = db.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Don't expose internal fields, just status
|
||||
res.json({
|
||||
sessionId: session.session_id,
|
||||
isActive: session.is_active === 1,
|
||||
createdAt: session.created_at,
|
||||
lastUpdate: session.last_update,
|
||||
expiresAt: session.expires_at
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error getting session:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/v1/session/:sessionId', getSessionHandler);
|
||||
app.get('/api/sessions/:sessionId', getSessionHandler);
|
||||
|
||||
/**
|
||||
* API: Get latest location for a session
|
||||
* GET /api/v1/location/:sessionId/latest (Android app format)
|
||||
* GET /api/sessions/:sessionId/location/latest (legacy format)
|
||||
*/
|
||||
const getLatestLocationHandler = (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
const location = db.getLatestLocation(sessionId);
|
||||
|
||||
if (!location) {
|
||||
return res.status(404).json({ error: 'No location data found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
encryptedPayload: location.encrypted_payload,
|
||||
timestamp: location.timestamp
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error getting location:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/v1/location/:sessionId/latest', getLatestLocationHandler);
|
||||
app.get('/api/sessions/:sessionId/location/latest', getLatestLocationHandler);
|
||||
|
||||
/**
|
||||
* API: Get location history for a session
|
||||
* GET /api/sessions/:sessionId/location/history?limit=100
|
||||
*/
|
||||
app.get('/api/sessions/:sessionId/location/history', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
|
||||
try {
|
||||
const history = db.getLocationHistory(sessionId, limit);
|
||||
|
||||
res.json({
|
||||
count: history.length,
|
||||
updates: history.map(loc => ({
|
||||
encryptedPayload: loc.encrypted_payload,
|
||||
timestamp: loc.timestamp
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error getting history:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* API: Stop a session
|
||||
* DELETE /api/v1/session/:sessionId (Android app format)
|
||||
* DELETE /api/sessions/:sessionId (legacy format)
|
||||
*/
|
||||
const deleteSessionHandler = (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
const result = db.stopSession(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Session stopped' });
|
||||
} catch (err) {
|
||||
console.error('Error stopping session:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
app.delete('/api/v1/session/:sessionId', deleteSessionHandler);
|
||||
app.delete('/api/sessions/:sessionId', deleteSessionHandler);
|
||||
|
||||
/**
|
||||
* API: Get server statistics
|
||||
* GET /api/stats
|
||||
*/
|
||||
app.get('/api/stats', (req, res) => {
|
||||
try {
|
||||
const stats = db.getStats();
|
||||
res.json(stats);
|
||||
} catch (err) {
|
||||
console.error('Error getting stats:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the viewer page for a specific session
|
||||
* GET /live/:encodedCredentials
|
||||
*/
|
||||
app.get('/live/:encodedCredentials', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'viewer.html'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Location sharing server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
console.log(`Database: ${process.env.DATABASE_PATH || 'location_sharing.db'}`);
|
||||
console.log(`\nServer stats:`, db.getStats());
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user