From 4f464a851307c4b431fa498b76b966ab885c1fb3 Mon Sep 17 00:00:00 2001 From: "pakintada@gmail.com" Date: Mon, 22 Jun 2026 10:55:47 +0700 Subject: [PATCH] change: security-check, memory-check [untest] - fix high risk security issues - fix high memory usage - change adb to connection pool single instance Signed-off-by: pakintada@gmail.com --- .../RemoteShellConfirmDialog.svelte | 15 +++ src/lib/components/dashboard-quick-adb.svelte | 13 ++- .../components/recipe-editor-dialog.svelte | 14 ++- src/lib/components/terminal-drawer.svelte | 15 +-- .../components/ui/terminal/terminal.svelte | 19 ++-- src/lib/core/adb/adb.ts | 102 ++++++++++++++++-- src/lib/core/auth/domainBlocker.ts | 15 +-- src/lib/core/handlers/messageHandler.ts | 6 +- src/lib/core/stores/adbConnectionStore.ts | 54 ++++++++++ src/lib/core/stores/noti.ts | 5 + src/lib/core/stores/remoteShellStore.ts | 41 ++++++- src/lib/core/stores/websocketStore.ts | 11 +- src/lib/server/auth.ts | 66 ++++++++++++ src/routes/(authed)/+layout.svelte | 3 +- src/routes/(authed)/entry/+page.svelte | 12 ++- .../(authed)/recipe/material/+page.svelte | 1 + .../(authed)/recipe/topping/+page.svelte | 1 + src/routes/(authed)/tools/brew/+page.svelte | 4 +- .../(authed)/tools/create-menu/+page.svelte | 14 +-- src/routes/api/adv-manifest/+server.ts | 4 + src/routes/api/adv-upload/+server.ts | 14 +++ src/routes/api/image-upload/+server.ts | 11 ++ src/routes/api/sheet/stream/+server.ts | 46 +++++++- 23 files changed, 413 insertions(+), 73 deletions(-) create mode 100644 src/lib/core/stores/adbConnectionStore.ts create mode 100644 src/lib/server/auth.ts 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} +
+

+ + Security Warning +

+

+ Command requested from server, this will not run unless you accepted. +

+
+ {/if} + {#if current.purposeDeclaration}
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)