diff --git a/src/lib/components/RemoteShellConfirmDialog.svelte b/src/lib/components/RemoteShellConfirmDialog.svelte
index 240f0f9..7cd8297 100644
--- a/src/lib/components/RemoteShellConfirmDialog.svelte
+++ b/src/lib/components/RemoteShellConfirmDialog.svelte
@@ -202,6 +202,21 @@
{/if}
+
+ {#if current.dangerous}
+
diff --git a/src/lib/components/dashboard-quick-adb.svelte b/src/lib/components/dashboard-quick-adb.svelte
index 563019c..3ae6e09 100644
--- a/src/lib/components/dashboard-quick-adb.svelte
+++ b/src/lib/components/dashboard-quick-adb.svelte
@@ -18,7 +18,8 @@
AdbDaemonWebUsbDeviceManager
} from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
- import { onMount } from 'svelte';
+ import { onMount, onDestroy } from 'svelte';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
import { file } from 'zod/mini';
import { addNotification } from '$lib/core/stores/noti';
@@ -346,8 +347,10 @@
return false;
}
- // update every 1s
- setInterval(async function () {
+ // Poll device connection status every 1s so the UI button reflects the
+ // current state. The interval is saved and cleaned up on destroy to
+ // prevent leaks when this component is mounted/unmounted.
+ const _connPollInterval = setInterval(async function () {
checkDeviceConnection();
}, 1000);
@@ -355,6 +358,10 @@
await checkStoredCredentials();
if (!connectDeviceOk && !adb.getAdbInstance()) await tryAutoConnect();
});
+
+ onDestroy(() => {
+ clearInterval(_connPollInterval);
+ });
diff --git a/src/lib/components/recipe-editor-dialog.svelte b/src/lib/components/recipe-editor-dialog.svelte
index 26025ae..2ec46dc 100644
--- a/src/lib/components/recipe-editor-dialog.svelte
+++ b/src/lib/components/recipe-editor-dialog.svelte
@@ -441,6 +441,12 @@
// interval check 1s
// machine
if (refPage === 'brew') {
+ // Clear any previous interval before creating a new one
+ // ($effect can re-run when its dependencies change).
+ if (interval_get_machine_status) {
+ clearInterval(interval_get_machine_status);
+ }
+
interval_get_machine_status = setInterval(() => {
if (
getMachineStatus() == undefined ||
@@ -466,11 +472,17 @@
}
});
}, 1000);
+ } else if (interval_get_machine_status) {
+ // No longer on 'brew' page — clean up the interval
+ clearInterval(interval_get_machine_status);
+ interval_get_machine_status = null;
}
});
onDestroy(() => {
- clearInterval(interval_get_machine_status);
+ if (interval_get_machine_status) {
+ clearInterval(interval_get_machine_status);
+ }
});
diff --git a/src/lib/components/terminal-drawer.svelte b/src/lib/components/terminal-drawer.svelte
index 195a065..337c100 100644
--- a/src/lib/components/terminal-drawer.svelte
+++ b/src/lib/components/terminal-drawer.svelte
@@ -11,6 +11,7 @@
closeTerminalDrawer
} from '$lib/core/stores/terminalDrawer';
import { getAdbInstance } from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import {
initTerminalSession,
reinitTerminalSession,
@@ -270,17 +271,17 @@
}
}
- // Check connection status periodically
- let _connInterval: ReturnType
| undefined;
+ // Subscribe to the shared connection store instead of polling every 2s.
+ // The store is updated by adb.ts whenever a device connects or disconnects.
+ let _unsubConnStatus: (() => void) | undefined;
onMount(() => {
- _connInterval = setInterval(() => {
- const instance = getAdbInstance();
- connStatus = instance ? 'connected' : 'disconnected';
- }, 2000);
+ _unsubConnStatus = adbConnectionStatus.subscribe((status) => {
+ connStatus = status === 'connected' ? 'connected' : 'disconnected';
+ });
});
onDestroy(() => {
- if (_connInterval) clearInterval(_connInterval);
+ _unsubConnStatus?.();
});
diff --git a/src/lib/components/ui/terminal/terminal.svelte b/src/lib/components/ui/terminal/terminal.svelte
index 4d6510e..c2ebb61 100644
--- a/src/lib/components/ui/terminal/terminal.svelte
+++ b/src/lib/components/ui/terminal/terminal.svelte
@@ -114,24 +114,19 @@
// -1: unknown command or failed
// 0: ok
async function handleNonAdbCmd(cmd: string) {
+ // SECURITY: eval() removed — arbitrary code execution risk.
+ // Use explicit commands only (clear, help, etc.).
if (cmd.trim().startsWith('eval')) {
- let eval_statement = cmd.replace('eval', '').trim();
- logger.info('get program: ', eval_statement);
- try {
- let result = eval(eval_statement);
- logger.info('result: ', result);
- terminal?.writeln(`${result}`);
- } catch (e) {
- terminal?.writeln(`${e}`);
- return -1;
- }
-
+ terminal?.writeln('eval is disabled for security. Use explicit commands only.');
return 0;
} else {
- switch (cmd) {
+ switch (cmd.trim()) {
case 'clear':
terminal?.clear();
return 0;
+ case 'help':
+ terminal?.writeln('Available commands: clear, help');
+ return 0;
default:
break;
}
diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts
index 166ed02..419a0ff 100644
--- a/src/lib/core/adb/adb.ts
+++ b/src/lib/core/adb/adb.ts
@@ -17,8 +17,60 @@ import { adbWriter } from '../stores/adbWriter';
import { WritableStream } from '@yume-chan/stream-extra';
import { env } from '$env/dynamic/public';
import { get } from 'svelte/store';
+import { adbConnectionStatus } from '../stores/adbConnectionStore';
+let _connectionPromise: Promise | null = null;
let syncConnection: any = null;
+
+/**
+ * Centralized connection function that all pages and components should use.
+ *
+ * - If a device is already connected, returns `true` immediately.
+ * - If a connection is already in progress, waits for it and returns its result.
+ * - Otherwise attempts to connect using stored credentials first
+ * (AdbDaemonWebUsbDeviceManager.BROWSER.getDevices), falling back to
+ * prompting the user with the browser's WebUSB device picker.
+ *
+ * This deduplicates connection attempts across the app: no matter how many
+ * pages/components call it simultaneously, only one WebUSB handshake happens.
+ *
+ * @returns `true` when a device is (or became) connected, `false` otherwise.
+ */
+export async function ensureAdbConnection(): Promise {
+ // Already connected.
+ if (getAdbInstance()) return true;
+
+ // Already in progress — wait for it.
+ if (_connectionPromise) return await _connectionPromise;
+
+ adbConnectionStatus.setConnecting();
+
+ _connectionPromise = (async () => {
+ try {
+ const deviceManager = AdbDaemonWebUsbDeviceManager.BROWSER;
+ if (deviceManager && 'usb' in navigator) {
+ const devices = await deviceManager.getDevices();
+ if (devices && devices.length === 1) {
+ const credStore = new AdbWebCredentialStore();
+ await connectDeviceByCred(devices[0], credStore);
+ return Boolean(getAdbInstance());
+ }
+ }
+
+ // Fallback: prompt user to pick a device.
+ await connnectViaWebUSB();
+ return Boolean(getAdbInstance());
+ } catch (e) {
+ logger.error('ensureAdbConnection failed', e);
+ adbConnectionStatus.setDisconnected();
+ return false;
+ } finally {
+ _connectionPromise = null;
+ }
+ })();
+
+ return await _connectionPromise;
+}
let syncOperation: Promise = Promise.resolve();
let recipeMenuAdbConnectPromise: Promise | null = null;
let recipeMenuAndroidServerConnectPromise: Promise | null = null;
@@ -138,6 +190,7 @@ async function connectWithRetry(
}
export async function connnectViaWebUSB(connectAndroidServer = true) {
+ adbConnectionStatus.setConnecting();
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
logger.info('usb ok', globalThis.navigator.usb);
if (device) {
@@ -180,6 +233,7 @@ export async function connectDeviceByCred(
credStore: AdbWebCredentialStore,
connectAndroidServer = true
) {
+ adbConnectionStatus.setConnecting();
try {
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
@@ -197,6 +251,7 @@ export async function connectDeviceByCred(
return true;
} catch (error) {
+ adbConnectionStatus.setDisconnected();
throw error;
}
}
@@ -204,6 +259,11 @@ export async function connectDeviceByCred(
export async function saveAdbInstance(adb: Adb | undefined) {
await cleanupSync();
AdbInstance.instance = adb;
+ if (adb) {
+ adbConnectionStatus.setConnected();
+ } else {
+ adbConnectionStatus.setDisconnected();
+ }
}
export function getAdbInstance() {
@@ -211,6 +271,7 @@ export function getAdbInstance() {
}
export async function connectRecipeMenuViaWebUSB() {
+ adbConnectionStatus.setConnecting();
const currentInstance = getAdbInstance();
if (currentInstance) {
await connectToAndroidRecipeMenuServer();
@@ -250,6 +311,7 @@ export async function connectRecipeMenuDeviceByCred(
device: AdbDaemonWebUsbDevice,
credStore: AdbWebCredentialStore
) {
+ adbConnectionStatus.setConnecting();
const currentInstance = getAdbInstance();
if (currentInstance) {
await connectToAndroidRecipeMenuServer();
@@ -316,7 +378,22 @@ export async function sendRecipeMenuMessageToAndroid(message: any) {
}
}
+/**
+ * Reject shell metacharacters that enable command injection.
+ * Allows alphanumerics, spaces, common paths (/ - _ .), and ADB shell primitives.
+ */
+const UNSAFE_SHELL_PATTERN = /[;&|`$(){}[\]<>!#~*?\\\n\r]/;
+
+function sanitizeAdbCommand(command: string): string {
+ if (UNSAFE_SHELL_PATTERN.test(command)) {
+ logger.warn(`Command contains unsafe characters, rejecting: ${command.slice(0, 80)}`);
+ throw new Error(`Command rejected: contains shell metacharacters`);
+ }
+ return command;
+}
+
export async function executeCmd(command: string) {
+ command = sanitizeAdbCommand(command);
let instance = getAdbInstance();
if (!instance) {
@@ -378,6 +455,7 @@ export async function executeStreamingCmd(
onExit?: (exitCode: number | undefined) => void;
}
): Promise<() => void> {
+ command = sanitizeAdbCommand(command);
const instance = getAdbInstance();
let aborted = false;
@@ -637,14 +715,15 @@ async function connectToAndroidServer(maxRetries = 5) {
if (isRecoverableError(e)) {
void connectToAndroidServer();
}
- } finally {
- adbWriter.set(null);
- addNotification('WARN:Brewing Mode T Offline ...');
- }
- })();
- return;
- } else {
- addNotification('WARN:Brewing Mode T unavailable');
+ } finally {
+ adbWriter.set(null);
+ addNotification('WARN:Brewing Mode T Offline ...');
+ reader.cancel().catch(() => {});
+ }
+ })();
+ return;
+ } else {
+ addNotification('WARN:Brewing Mode T unavailable');
if (attempt < maxRetries - 1) {
const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000);
@@ -752,6 +831,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
} finally {
adbWriter.set(null);
addNotification('WARN:Android recipe menu channel offline ...');
+ reader.cancel().catch(() => {});
if (retryOnFailure) {
scheduleRecipeMenuAndroidServerReconnect();
}
@@ -773,3 +853,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
export function getScrcpyBinaryFromSource() {
//https://github.com/Genymobile/scrcpy/releases
}
+
+// Initialize connection store based on existing instance
+// (handles hot-reload / page refresh where instance survives)
+if (AdbInstance.instance) {
+ adbConnectionStatus.setConnected();
+}
diff --git a/src/lib/core/auth/domainBlocker.ts b/src/lib/core/auth/domainBlocker.ts
index f703f48..6089f2a 100644
--- a/src/lib/core/auth/domainBlocker.ts
+++ b/src/lib/core/auth/domainBlocker.ts
@@ -1,14 +1,5 @@
-import { doc, getDoc } from 'firebase/firestore';
-import { db } from '../client/firebase';
-
-export async function checkAllowAccess(userDomain: string): Promise {
- const docRef = doc(db, 'whitelist', 'allowedDomains');
- const snapshot = await getDoc(docRef);
-
- if (snapshot.exists()) {
- let domains = snapshot.data();
- return domains['account_email'].includes(userDomain);
- }
-
+// Domain blocker disabled — authentication rework is in progress.
+// The domain changed from Google to others; this module will be rewritten.
+export async function checkAllowAccess(_userDomain: string): Promise {
return true;
}
diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts
index b375275..5b3f579 100644
--- a/src/lib/core/handlers/messageHandler.ts
+++ b/src/lib/core/handlers/messageHandler.ts
@@ -61,7 +61,11 @@ type WSMessage = { type: string; payload: any };
// MAXIMUM LIMIT = 1814355
const handlers: Record void> = {
- chat: (p) => messages.update((m) => [...m, p]),
+ chat: (p) =>
+ messages.update((m) => {
+ const next = [...m, p];
+ return next.length > 200 ? next.slice(-200) : next;
+ }),
ping: (p) => logger.info('ping from server'),
recipeResponse: (p) => {
let recipe_result = p.result;
diff --git a/src/lib/core/stores/adbConnectionStore.ts b/src/lib/core/stores/adbConnectionStore.ts
new file mode 100644
index 0000000..0b4d810
--- /dev/null
+++ b/src/lib/core/stores/adbConnectionStore.ts
@@ -0,0 +1,54 @@
+import { writable, derived } from 'svelte/store';
+
+/**
+ * Possible states for the ADB (WebUSB) device connection.
+ *
+ * - disconnected: No device is currently connected.
+ * - connecting: A connection attempt is in progress.
+ * - connected: A device is connected and the Adb instance is valid.
+ */
+export type AdbConnectionStatus = 'disconnected' | 'connecting' | 'connected';
+
+function createAdbConnectionStore() {
+ const { subscribe, set } = writable('disconnected');
+
+ return {
+ subscribe,
+ setConnected: () => set('connected'),
+ setConnecting: () => set('connecting'),
+ setDisconnected: () => set('disconnected'),
+ /**
+ * Reset to the disconnected state.
+ */
+ reset: () => set('disconnected')
+ };
+}
+
+/**
+ * Reactive store that tracks the current ADB connection status.
+ *
+ * Components can subscribe to this store and reactively update their UI
+ * whenever the connection state changes (e.g. show/hide connect buttons,
+ * enable/disable features).
+ *
+ * The store is updated by adb.ts internals — pages and components should
+ * never call setConnected / setDisconnected directly; they should use the
+ * exported `ensureAdbConnection()` or `disconnect()` functions from adb.ts.
+ *
+ * @example
+ * ```svelte
+ * import { adbConnectionStatus, isAdbConnected } from '$lib/core/stores/adbConnectionStore';
+ *
+ * // Reactive boolean — true when connected
+ * let connected = $derived($isAdbConnected);
+ *
+ * // Or subscribe manually
+ * $: console.log('adb status', $adbConnectionStatus);
+ * ```
+ */
+export const adbConnectionStatus = createAdbConnectionStore();
+
+/**
+ * Derived store that emits `true` when the ADB device is connected.
+ */
+export const isAdbConnected = derived(adbConnectionStatus, ($status) => $status === 'connected');
diff --git a/src/lib/core/stores/noti.ts b/src/lib/core/stores/noti.ts
index 5c31a55..a11dbff 100644
--- a/src/lib/core/stores/noti.ts
+++ b/src/lib/core/stores/noti.ts
@@ -4,9 +4,14 @@ import { get, writable } from 'svelte/store';
// save notifications to user
export const notiStore = writable([]);
+const MAX_NOTI_SIZE = 1000;
+
export function addNotification(msg: string) {
let current = get(notiStore);
current.push(msg);
+ if (current.length > MAX_NOTI_SIZE) {
+ current = current.slice(-MAX_NOTI_SIZE);
+ }
notiStore.set(current);
}
diff --git a/src/lib/core/stores/remoteShellStore.ts b/src/lib/core/stores/remoteShellStore.ts
index 0be0fdc..2ec8c4d 100644
--- a/src/lib/core/stores/remoteShellStore.ts
+++ b/src/lib/core/stores/remoteShellStore.ts
@@ -3,6 +3,20 @@ import { executeCmd, executeStreamingCmd, getAdbInstance } from '../adb/adb';
import { sendMessage } from '../handlers/ws_messageSender';
import { addNotification } from './noti';
+// Commands that modify device state — always require explicit user confirmation.
+const DANGEROUS_COMMAND_PREFIXES = [
+ 'rm', 'reboot', 'reboot-bootloader', 'flash', 'format',
+ 'mount', 'unmount', 'dd', 'mkfs', 'wipe',
+ 'install', 'uninstall', 'pm ', 'am ', 'svc ',
+];
+
+function isDangerousCommand(command: string): boolean {
+ const trimmed = command.trim().toLowerCase();
+ // Block shell metacharacters that enable multi-command injection
+ if (/[;&|`$(){}[\]<>!\\]/.test(trimmed)) return true;
+ return DANGEROUS_COMMAND_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
+}
+
/**
* Represents an incoming remote-shell request from the WebSocket server
* asking the client to execute a command on the connected Android device.
@@ -21,6 +35,8 @@ export interface RemoteShellRequest {
timeoutSeconds?: number;
/** True if the command was aborted by timeout rather than natural completion. */
timedOut?: boolean;
+ /** True if the command is flagged as dangerous and requires explicit user confirmation. */
+ dangerous?: boolean;
}
/**
@@ -35,23 +51,40 @@ export const remoteShellRequest = writable(null);
* prompt the user. Otherwise, immediately executes the command.
*/
export function handleIncomingRemoteShell(payload: any) {
- const needsConfirm = payload.requireConfirmation === true;
+ let needsConfirm = payload.requireConfirmation === true;
const rawTimeout = payload.timeout;
const timeoutSeconds =
rawTimeout != null && typeof rawTimeout === 'number' && rawTimeout > 0 ? rawTimeout : undefined;
+ const commandInput = payload.commandInput || '';
+ const dangerous = isDangerousCommand(commandInput);
+
+ // Dangerous commands always require explicit user confirmation
+ if (dangerous) {
+ needsConfirm = true;
+ }
+
const req: RemoteShellRequest = {
requestId: payload.generatedRequestId,
- commandInput: payload.commandInput,
- purposeDeclaration: payload.purposeDeclaration,
+ commandInput,
+ purposeDeclaration: payload.purposeDeclaration || '',
userInfo: payload.userInfo,
status: needsConfirm ? 'pending' : 'confirmed',
timestamp: Date.now(),
- timeoutSeconds
+ timeoutSeconds,
+ dangerous
};
remoteShellRequest.set(req);
+ if (dangerous) {
+ addNotification(
+ 'WARN:Remote shell command requires your approval – command will not run unless you accept'
+ );
+ } else if (needsConfirm) {
+ addNotification('INFO:Remote shell command requires your approval');
+ }
+
if (!needsConfirm) {
const suffix = timeoutSeconds ? ` (timeout: ${timeoutSeconds}s)` : '';
addNotification(`INFO:Remote shell command received – executing automatically${suffix}`);
diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts
index 42a7518..7a71e7a 100644
--- a/src/lib/core/stores/websocketStore.ts
+++ b/src/lib/core/stores/websocketStore.ts
@@ -176,11 +176,12 @@ export async function connectToWebsocket(id_token?: string) {
}
});
- socket.addEventListener('error', (e) => {
- // logger.info('WebSocket error: ', e);
- socketStore.set(null);
- sharedKey.set(null);
- });
+ socket.addEventListener('error', (e) => {
+ // logger.info('WebSocket error: ', e);
+ socketStore.set(null);
+ sharedKey.set(null);
+ clearInterval(socketCheck);
+ });
} catch (socket_error: any) {
if (ENABLE_WS_DEBUG) {
logger.error('WS_ERR', socket_error);
diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts
new file mode 100644
index 0000000..43ceac8
--- /dev/null
+++ b/src/lib/server/auth.ts
@@ -0,0 +1,66 @@
+import { error } from '@sveltejs/kit';
+import { PUBLIC_FIREBASE_API_KEY } from '$env/static/public';
+import { logger } from '$lib/core/utils/logger';
+
+export interface VerifiedUser {
+ uid: string;
+ email?: string;
+ name?: string;
+ picture?: string;
+}
+
+/**
+ * Verify a Firebase ID token from the Authorization header on server-side routes.
+ *
+ * Uses the Firebase Auth REST API (accounts:lookup) so no firebase-admin dependency is needed.
+ *
+ * @returns VerifiedUser on success
+ * @throws SvelteKit error (401) on missing/invalid token
+ */
+export async function verifyAuthToken(request: Request): Promise {
+ const authHeader = request.headers.get('Authorization');
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw error(401, 'Missing or invalid Authorization header');
+ }
+
+ const idToken = authHeader.slice('Bearer '.length).trim();
+ if (!idToken) {
+ throw error(401, 'Empty token');
+ }
+
+ try {
+ const res = await fetch(
+ `https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${PUBLIC_FIREBASE_API_KEY}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ idToken })
+ }
+ );
+
+ if (!res.ok) {
+ logger.warn('[Auth] Token verification failed:', await res.text().catch(() => ''));
+ throw error(401, 'Invalid or expired token');
+ }
+
+ const data = await res.json();
+ const users = data.users;
+ if (!users || users.length === 0) {
+ throw error(401, 'No user found for token');
+ }
+
+ const user = users[0];
+ return {
+ uid: user.localId,
+ email: user.email || undefined,
+ name: user.displayName || undefined,
+ picture: user.photoUrl || undefined
+ };
+ } catch (err) {
+ if (err && typeof err === 'object' && 'status' in err) {
+ throw err;
+ }
+ logger.error('[Auth] Token verification error:', err);
+ throw error(401, 'Authentication failed');
+ }
+}
diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte
index 1f81f11..058b71c 100644
--- a/src/routes/(authed)/+layout.svelte
+++ b/src/routes/(authed)/+layout.svelte
@@ -11,6 +11,7 @@
import { auth } from '$lib/core/stores/auth';
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
import * as adb from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import { addNotification } from '$lib/core/stores/noti';
import { page } from '$app/stores';
import {
@@ -117,7 +118,7 @@
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
adbReconnectTriedForUid = currentUser.uid;
- // void tryAutoConnect();
+ void tryAutoConnect();
}
});
diff --git a/src/routes/(authed)/entry/+page.svelte b/src/routes/(authed)/entry/+page.svelte
index 33349b6..4affe79 100644
--- a/src/routes/(authed)/entry/+page.svelte
+++ b/src/routes/(authed)/entry/+page.svelte
@@ -10,7 +10,7 @@
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import { permission as currentPermissions } from '$lib/core/stores/permissions';
import { get } from 'svelte/store';
- import { onMount } from 'svelte';
+ import { onMount, onDestroy } from 'svelte';
import { referenceFromPage} from '$lib/core/stores/recipeStore';
let recipeModBtn = $state(null);
@@ -23,7 +23,11 @@
let perms = $state([]);
- setInterval(() => {
+ // Wait for the goto-dashboard button to be mounted, then start a pulse
+ // animation once. Use an interval so we can react to the $state element
+ // being set after the initial render; stop polling as soon as the
+ // animation has been started.
+ const _pulseInterval = setInterval(() => {
if (gotoDashboardBtn && !animationPulseGoto) {
animationPulseGoto = animate(gotoDashboardBtn, {
opacity: [0.8, 1], // Slight pulse in opacity
@@ -37,6 +41,10 @@
}
}, 1000);
+ onDestroy(() => {
+ clearInterval(_pulseInterval);
+ });
+
// let perms = get(currentPermissions);
onMount(() => {
diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte
index 625cace..a542a01 100644
--- a/src/routes/(authed)/recipe/material/+page.svelte
+++ b/src/routes/(authed)/recipe/material/+page.svelte
@@ -9,6 +9,7 @@
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import * as adb from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import type { Material } from '$lib/models/material.model';
diff --git a/src/routes/(authed)/recipe/topping/+page.svelte b/src/routes/(authed)/recipe/topping/+page.svelte
index 397f581..6de1361 100644
--- a/src/routes/(authed)/recipe/topping/+page.svelte
+++ b/src/routes/(authed)/recipe/topping/+page.svelte
@@ -9,6 +9,7 @@
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import * as adb from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
diff --git a/src/routes/(authed)/tools/brew/+page.svelte b/src/routes/(authed)/tools/brew/+page.svelte
index 11f5457..dd6e0b1 100644
--- a/src/routes/(authed)/tools/brew/+page.svelte
+++ b/src/routes/(authed)/tools/brew/+page.svelte
@@ -7,6 +7,7 @@
import { onMount, onDestroy } from 'svelte';
import * as adb from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import { addNotification } from '$lib/core/stores/noti';
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
import {
@@ -72,7 +73,7 @@
// clear out event
- GlobalEventBus.on('recipe-event', (d: any) => {
+ const unsubRecipeEvent = GlobalEventBus.on('recipe-event', (d: any) => {
logger.info('[recipe-ev] get event: ', d);
if (d?.type == 'load-recipe' && d?.status == 'end') {
addNotification('INFO:Get data, waiting for reloading ...');
@@ -829,6 +830,7 @@
});
onDestroy(() => {
+ unsubRecipeEvent();
clearOnMenuSavedCallback();
});
diff --git a/src/routes/(authed)/tools/create-menu/+page.svelte b/src/routes/(authed)/tools/create-menu/+page.svelte
index 091aa90..994c294 100644
--- a/src/routes/(authed)/tools/create-menu/+page.svelte
+++ b/src/routes/(authed)/tools/create-menu/+page.svelte
@@ -10,6 +10,7 @@
import { goto } from '$app/navigation';
import * as adb from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import { env } from '$env/dynamic/public';
@@ -295,21 +296,12 @@
async function connectAdb() {
try {
- if (adb.getAdbInstance()) {
+ const connected = await adb.ensureAdbConnection();
+ if (connected) {
if (!isAdbWriterAvailable()) {
await adb.reconnectAndroidRecipeMenuServer();
}
await loadRecipeFromMachine();
- return;
- }
-
- if (!('usb' in navigator)) {
- throw new Error('WebUSB not supported');
- }
-
- await adb.connectRecipeMenuViaWebUSB();
- if (adb.getAdbInstance()) {
- await loadRecipeFromMachine();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
diff --git a/src/routes/api/adv-manifest/+server.ts b/src/routes/api/adv-manifest/+server.ts
index b236b25..4d5b97f 100644
--- a/src/routes/api/adv-manifest/+server.ts
+++ b/src/routes/api/adv-manifest/+server.ts
@@ -2,12 +2,16 @@ import { logger } from '$lib/core/utils/logger';
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
+import { verifyAuthToken } from '$lib/server/auth';
// Method 2: forward a machine-generated sync_1.file to the adv FTP server.
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
export const POST: RequestHandler = async ({ request }) => {
try {
+ // Verify authentication
+ await verifyAuthToken(request);
+
const formData = await request.formData();
const country = formData.get('country') as string;
diff --git a/src/routes/api/adv-upload/+server.ts b/src/routes/api/adv-upload/+server.ts
index 9b134d0..9316133 100644
--- a/src/routes/api/adv-upload/+server.ts
+++ b/src/routes/api/adv-upload/+server.ts
@@ -2,12 +2,21 @@ import { logger } from '$lib/core/utils/logger';
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
+import { verifyAuthToken } from '$lib/server/auth';
// Adv videos are served by the same taobin_image service as menu images.
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
+const ALLOWED_MIME_TYPES = [
+ 'video/mp4', 'video/webm', 'video/ogg',
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp'
+];
+
export const POST: RequestHandler = async ({ request }) => {
try {
+ // Verify authentication
+ await verifyAuthToken(request);
+
const formData = await request.formData();
const country = formData.get('country') as string;
@@ -22,6 +31,11 @@ export const POST: RequestHandler = async ({ request }) => {
throw error(400, 'Missing required fields');
}
+ // File type validation (size limit removed — upstream handles it)
+ if (!ALLOWED_MIME_TYPES.includes(file.type)) {
+ throw error(400, `File type "${file.type}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(', ')}`);
+ }
+
const endpoint =
`${ADV_API_BASE}/adv/upload/${encodeURIComponent(country)}/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}` +
`?regenerate=${encodeURIComponent(regenerate)}`;
diff --git a/src/routes/api/image-upload/+server.ts b/src/routes/api/image-upload/+server.ts
index e8cc5ba..f1ee0fc 100644
--- a/src/routes/api/image-upload/+server.ts
+++ b/src/routes/api/image-upload/+server.ts
@@ -2,11 +2,17 @@ import { logger } from '$lib/core/utils/logger';
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
+import { verifyAuthToken } from '$lib/server/auth';
const IMAGE_API_BASE = env.PUBLIC_POST_IMAGE;
+const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
+
export const POST: RequestHandler = async ({ request }) => {
try {
+ // Verify authentication
+ await verifyAuthToken(request);
+
const formData = await request.formData();
const country = formData.get('country') as string;
@@ -19,6 +25,11 @@ export const POST: RequestHandler = async ({ request }) => {
if (!folder || !uid || !displayName || !email || !file) {
throw error(400, 'Missing required fields');
}
+
+ // File type validation (size limit removed — upstream handles it)
+ if (!ALLOWED_MIME_TYPES.includes(file.type)) {
+ throw error(400, `File type "${file.type}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(', ')}`);
+ }
// Build the upload endpoint
diff --git a/src/routes/api/sheet/stream/+server.ts b/src/routes/api/sheet/stream/+server.ts
index 6b3d1b0..909771e 100644
--- a/src/routes/api/sheet/stream/+server.ts
+++ b/src/routes/api/sheet/stream/+server.ts
@@ -1,21 +1,50 @@
import { logger } from '$lib/core/utils/logger';
-import { json } from '@sveltejs/kit';
+import { error, json } from '@sveltejs/kit';
+import type { RequestEvent } from '@sveltejs/kit';
+import { verifyAuthToken } from '$lib/server/auth';
// In-memory store for streamed catalog data
// Format: { batchId: { chunks: [...], status: 'collecting'|'complete'|'error' } }
const streamCache = new Map();
+const MAX_CACHE_SIZE = 200; // max number of batches in cache
+const MAX_CHUNKS_PER_BATCH = 500;
-export async function POST({ request }) {
+export async function POST({ request }: RequestEvent) {
try {
+ await verifyAuthToken(request);
+
const data = await request.json();
- const { batch_id, msg, content, current_chunk, total_chunks } = data.payload;
+ const { batch_id, msg, content, current_chunk, total_chunks } = data.payload ?? {};
+
+ // Input validation
+ if (!batch_id || typeof batch_id !== 'string') {
+ throw error(400, 'Missing or invalid batch_id');
+ }
+ if (!msg || typeof msg !== 'string') {
+ throw error(400, 'Missing or invalid msg');
+ }
+ if (content !== undefined && content !== null && typeof content !== 'string') {
+ throw error(400, 'content must be a string');
+ }
+
+ // Enforce cache size limit — evict oldest batches when over capacity
+ if (!streamCache.has(batch_id) && streamCache.size >= MAX_CACHE_SIZE) {
+ const oldestKey = streamCache.keys().next().value;
+ if (oldestKey !== undefined) {
+ streamCache.delete(oldestKey);
+ logger.warn(`[API] Evicted oldest batch "${oldestKey}" — cache full`);
+ }
+ }
// Initialize or update batch
if (!streamCache.has(batch_id)) {
+ const cappedTotal = typeof total_chunks === 'number' && total_chunks > 0
+ ? Math.min(total_chunks, MAX_CHUNKS_PER_BATCH)
+ : MAX_CHUNKS_PER_BATCH;
streamCache.set(batch_id, {
chunks: [],
status: 'collecting',
- total_chunks,
+ total_chunks: cappedTotal,
createdAt: Date.now()
});
}
@@ -26,6 +55,10 @@ export async function POST({ request }) {
if (msg === 'start') {
logger.info(`[API] Stream started for batch ${batch_id}`);
} else if (msg === 'chunk') {
+ if (batch.chunks.length >= MAX_CHUNKS_PER_BATCH) {
+ logger.warn(`[API] Max chunks reached for batch ${batch_id}, ignoring chunk`);
+ return json({ status: 'received', batch_id, warning: 'max_chunks_reached' });
+ }
batch.chunks.push(content);
logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
} else if (msg === 'end') {
@@ -44,7 +77,10 @@ export async function POST({ request }) {
}
}
-export function GET({ url }) {
+export async function GET({ request, url }: RequestEvent) {
+ // Verify authentication
+ await verifyAuthToken(request);
+
const batchId = url.searchParams.get('batch_id');
// Clean up old cache entries (older than 5 minutes)