v9: APK hardening, service worker caching, admin model validation, Docker v9
- APK: Add WAKE_LOCK, BOOT_COMPLETED, ACCESS_NETWORK_STATE permissions - APK: Disable allowBackup for medical data security - APK: AudioRecordingService now acquires wake lock, has stop action in notification - Serve /.well-known/assetlinks.json for TWA domain verification - Service worker: cache app shell, stale-while-revalidate for assets, network-first for API - Admin model management: validate model ID format, prevent built-in conflicts, audit toggle actions, prevent disabling all models - Bump version to v9.0.0, Docker tag to v9
This commit is contained in:
parent
4e5b6fed5a
commit
1ff0f9760d
8 changed files with 182 additions and 23 deletions
|
|
@ -6,9 +6,12 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:label="${launcherName}"
|
android:label="${launcherName}"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,29 @@ package com.pediatricscribe.twa;
|
||||||
import android.app.Notification;
|
import android.app.Notification;
|
||||||
import android.app.NotificationChannel;
|
import android.app.NotificationChannel;
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground service that keeps the app alive during audio recording.
|
* Foreground service that keeps the app alive during audio recording.
|
||||||
|
* Acquires a partial wake lock to prevent CPU sleep during recording.
|
||||||
* The TWA web app sends a message to start/stop this service when recording.
|
* The TWA web app sends a message to start/stop this service when recording.
|
||||||
*/
|
*/
|
||||||
public class AudioRecordingService extends Service {
|
public class AudioRecordingService extends Service {
|
||||||
|
|
||||||
private static final String CHANNEL_ID = "recording_channel";
|
private static final String CHANNEL_ID = "recording_channel";
|
||||||
private static final int NOTIFICATION_ID = 1;
|
private static final int NOTIFICATION_ID = 1;
|
||||||
|
private static final String WAKE_LOCK_TAG = "PedScribe:AudioRecording";
|
||||||
|
|
||||||
|
public static final String ACTION_STOP = "com.pediatricscribe.twa.STOP_RECORDING";
|
||||||
|
|
||||||
|
private PowerManager.WakeLock wakeLock;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
|
|
@ -27,12 +35,34 @@ public class AudioRecordingService extends Service {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||||
|
stopSelf();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire wake lock to keep CPU active during recording
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||||
|
if (pm != null) {
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
|
||||||
|
wakeLock.acquire(60 * 60 * 1000L); // 1 hour max
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop action in notification
|
||||||
|
Intent stopIntent = new Intent(this, AudioRecordingService.class);
|
||||||
|
stopIntent.setAction(ACTION_STOP);
|
||||||
|
PendingIntent stopPending = PendingIntent.getService(
|
||||||
|
this, 0, stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||||
|
);
|
||||||
|
|
||||||
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Pediatric AI Scribe")
|
.setContentTitle("Pediatric AI Scribe")
|
||||||
.setContentText("Recording in progress...")
|
.setContentText("Recording in progress...")
|
||||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, notification);
|
startForeground(NOTIFICATION_ID, notification);
|
||||||
|
|
@ -46,6 +76,10 @@ public class AudioRecordingService extends Service {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
|
if (wakeLock != null && wakeLock.isHeld()) {
|
||||||
|
wakeLock.release();
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
stopForeground(true);
|
stopForeground(true);
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +92,7 @@ public class AudioRecordingService extends Service {
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
);
|
);
|
||||||
channel.setDescription("Shows when audio recording is active");
|
channel.setDescription("Shows when audio recording is active");
|
||||||
|
channel.setShowBadge(false);
|
||||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||||
if (manager != null) {
|
if (manager != null) {
|
||||||
manager.createNotificationChannel(channel);
|
manager.createNotificationChannel(channel);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
pediatric-scribe:
|
pediatric-scribe:
|
||||||
image: danielonyejesi/pediatric-ai-scribe-v3:v8
|
image: danielonyejesi/pediatric-ai-scribe-v3:v9
|
||||||
ports:
|
ports:
|
||||||
- "3552:3000"
|
- "3552:3000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "8.0.0",
|
"version": "9.0.0",
|
||||||
"description": "AI-powered pediatric clinical documentation platform",
|
"description": "AI-powered pediatric clinical documentation platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
12
public/.well-known/assetlinks.json
Normal file
12
public/.well-known/assetlinks.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.pediatricscribe.twa",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"TO_BE_REPLACED_WITH_YOUR_SIGNING_KEY_SHA256"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
102
public/sw.js
102
public/sw.js
|
|
@ -1,22 +1,94 @@
|
||||||
// Minimal service worker — pass everything through, cache nothing
|
// ============================================================
|
||||||
self.addEventListener('install', function() {
|
// SERVICE WORKER — Cache shell, network-first for API
|
||||||
self.skipWaiting();
|
// Provides offline fallback for app shell while keeping
|
||||||
});
|
// API calls always fresh (critical for medical data accuracy)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
self.addEventListener('activate', function(event) {
|
var CACHE_NAME = 'pedscribe-v11';
|
||||||
// Clear all old caches
|
var SHELL_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/css/styles.css',
|
||||||
|
'/js/app.js',
|
||||||
|
'/js/auth.js',
|
||||||
|
'/manifest.json'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: precache app shell
|
||||||
|
self.addEventListener('install', function(event) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then(function(names) {
|
caches.open(CACHE_NAME).then(function(cache) {
|
||||||
return Promise.all(
|
return cache.addAll(SHELL_ASSETS);
|
||||||
names.map(function(name) { return caches.delete(name); })
|
}).then(function() {
|
||||||
);
|
return self.skipWaiting();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', function(event) {
|
// Activate: clear old caches
|
||||||
// Only intercept same-origin requests — let external CDNs go through natively
|
self.addEventListener('activate', function(event) {
|
||||||
if (event.request.url.startsWith(self.location.origin)) {
|
event.waitUntil(
|
||||||
event.respondWith(fetch(event.request));
|
caches.keys().then(function(names) {
|
||||||
}
|
return Promise.all(
|
||||||
|
names.filter(function(name) { return name !== CACHE_NAME; })
|
||||||
|
.map(function(name) { return caches.delete(name); })
|
||||||
|
);
|
||||||
|
}).then(function() {
|
||||||
|
return self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch: network-first for API, cache-first for static assets
|
||||||
|
self.addEventListener('fetch', function(event) {
|
||||||
|
var url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Only handle same-origin requests
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// API calls — always network, never cache (medical data must be fresh)
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component HTML — network-first with cache fallback
|
||||||
|
if (url.pathname.startsWith('/components/')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).then(function(response) {
|
||||||
|
var clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
|
||||||
|
return response;
|
||||||
|
}).catch(function() {
|
||||||
|
return caches.match(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets (JS, CSS, icons) — stale-while-revalidate
|
||||||
|
if (url.pathname.match(/\.(js|css|png|ico|woff2?)$/)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(function(cached) {
|
||||||
|
var fetchPromise = fetch(event.request).then(function(response) {
|
||||||
|
var clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
|
||||||
|
return response;
|
||||||
|
}).catch(function() { return cached; });
|
||||||
|
return cached || fetchPromise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages — network-first
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).then(function(response) {
|
||||||
|
var clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
|
||||||
|
return response;
|
||||||
|
}).catch(function() {
|
||||||
|
return caches.match(event.request) || caches.match('/index.html');
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
11
server.js
11
server.js
|
|
@ -88,6 +88,13 @@ app.use('/api/auth/resend-verification', rateLimit({
|
||||||
standardHeaders: true, legacyHeaders: false
|
standardHeaders: true, legacyHeaders: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Serve .well-known/assetlinks.json for TWA verification (must be before static)
|
||||||
|
app.get('/.well-known/assetlinks.json', (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.sendFile(path.join(__dirname, 'public', '.well-known', 'assetlinks.json'));
|
||||||
|
});
|
||||||
|
|
||||||
app.use(loggingMiddleware);
|
app.use(loggingMiddleware);
|
||||||
app.use(express.static(path.join(__dirname, 'public'), {
|
app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
|
|
@ -134,7 +141,7 @@ app.get('/api/models', async (req, res) => {
|
||||||
const { activeProvider } = require('./src/utils/ai');
|
const { activeProvider } = require('./src/utils/ai');
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'running', version: '3.1.0', provider: activeProvider,
|
status: 'running', version: '9.0.0', provider: activeProvider,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
||||||
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
||||||
|
|
@ -197,7 +204,7 @@ const PORT = process.env.PORT || 3000;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('🏥 PEDIATRIC AI SCRIBE v3.1');
|
console.log('🏥 PEDIATRIC AI SCRIBE v9.0');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('🌐 http://localhost:' + PORT);
|
console.log('🌐 http://localhost:' + PORT);
|
||||||
console.log('🤖 Provider: ' + activeProvider);
|
console.log('🤖 Provider: ' + activeProvider);
|
||||||
|
|
|
||||||
|
|
@ -257,9 +257,21 @@ router.put('/config/models/toggle', async function(req, res) {
|
||||||
disabled = disabled.filter(function(id) { return id !== modelId; });
|
disabled = disabled.filter(function(id) { return id !== modelId; });
|
||||||
} else {
|
} else {
|
||||||
if (!disabled.includes(modelId)) disabled.push(modelId);
|
if (!disabled.includes(modelId)) disabled.push(modelId);
|
||||||
|
|
||||||
|
// Safety: prevent disabling all models
|
||||||
|
var { getAvailableModels } = require('../utils/models');
|
||||||
|
var allModels = getAvailableModels();
|
||||||
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||||
|
var custom;
|
||||||
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
|
var enabledCount = allModels.filter(function(m) { return !disabled.includes(m.id); }).length + custom.length;
|
||||||
|
if (enabledCount === 0) {
|
||||||
|
return res.status(400).json({ error: 'Cannot disable all models. At least one model must remain enabled.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
||||||
|
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
@ -270,16 +282,34 @@ router.post('/config/models/custom', async function(req, res) {
|
||||||
var { id, name, cost, category } = req.body;
|
var { id, name, cost, category } = req.body;
|
||||||
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
||||||
|
|
||||||
|
// Validate model ID format (provider/model-name)
|
||||||
|
var trimmedId = id.trim();
|
||||||
|
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
|
||||||
|
if (!/^[a-zA-Z0-9._\-\/\:]+$/.test(trimmedId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid model ID format. Use only letters, numbers, dots, dashes, slashes, and colons.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if model ID conflicts with a built-in model
|
||||||
|
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS } = require('../utils/models');
|
||||||
|
var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS);
|
||||||
|
if (allBuiltIn.find(function(m) { return m.id === trimmedId; })) {
|
||||||
|
return res.status(400).json({ error: 'Model ID conflicts with a built-in model. Use the toggle to enable/disable built-in models.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var validCategories = ['free', 'fast', 'smart', 'premium'];
|
||||||
|
var cat = validCategories.includes(category) ? category : 'smart';
|
||||||
|
|
||||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||||
var custom;
|
var custom;
|
||||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
|
|
||||||
// Prevent duplicates
|
// Prevent duplicates
|
||||||
custom = custom.filter(function(m) { return m.id !== id; });
|
var existing = custom.find(function(m) { return m.id === trimmedId; });
|
||||||
custom.push({ id: id.trim(), name: name.trim(), cost: cost || '?', category: category || 'smart', tag: 'CUSTOM' });
|
custom = custom.filter(function(m) { return m.id !== trimmedId; });
|
||||||
|
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' });
|
||||||
|
|
||||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||||
logger.audit(req.user.id, 'admin_model_add', 'Added custom model: ' + id, req, { category: 'admin' });
|
logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue