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 <Pakin>
This commit is contained in:
parent
f0619c5a10
commit
4f464a8513
23 changed files with 413 additions and 73 deletions
|
|
@ -202,6 +202,21 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dangerous command warning -->
|
||||||
|
{#if current.dangerous}
|
||||||
|
<div class="mb-4 rounded-lg border-2 border-red-500 bg-red-50 p-4 dark:border-red-600 dark:bg-red-900/30">
|
||||||
|
<p
|
||||||
|
class="mb-1 text-xs font-semibold tracking-wider text-red-600 uppercase dark:text-red-400"
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon size={14} class="mr-1 inline" />
|
||||||
|
Security Warning
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
Command requested from server, this will not run unless you accepted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Purpose declaration -->
|
<!-- Purpose declaration -->
|
||||||
{#if current.purposeDeclaration}
|
{#if current.purposeDeclaration}
|
||||||
<div class="mb-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800/50">
|
<div class="mb-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800/50">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
AdbDaemonWebUsbDeviceManager
|
AdbDaemonWebUsbDeviceManager
|
||||||
} from '@yume-chan/adb-daemon-webusb';
|
} from '@yume-chan/adb-daemon-webusb';
|
||||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
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 { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||||
import { file } from 'zod/mini';
|
import { file } from 'zod/mini';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
|
|
@ -346,8 +347,10 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update every 1s
|
// Poll device connection status every 1s so the UI button reflects the
|
||||||
setInterval(async function () {
|
// 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();
|
checkDeviceConnection();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
|
@ -355,6 +358,10 @@
|
||||||
await checkStoredCredentials();
|
await checkStoredCredentials();
|
||||||
if (!connectDeviceOk && !adb.getAdbInstance()) await tryAutoConnect();
|
if (!connectDeviceOk && !adb.getAdbInstance()) await tryAutoConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearInterval(_connPollInterval);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,12 @@
|
||||||
// interval check 1s
|
// interval check 1s
|
||||||
// machine
|
// machine
|
||||||
if (refPage === 'brew') {
|
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(() => {
|
interval_get_machine_status = setInterval(() => {
|
||||||
if (
|
if (
|
||||||
getMachineStatus() == undefined ||
|
getMachineStatus() == undefined ||
|
||||||
|
|
@ -466,11 +472,17 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 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(() => {
|
onDestroy(() => {
|
||||||
clearInterval(interval_get_machine_status);
|
if (interval_get_machine_status) {
|
||||||
|
clearInterval(interval_get_machine_status);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
closeTerminalDrawer
|
closeTerminalDrawer
|
||||||
} from '$lib/core/stores/terminalDrawer';
|
} from '$lib/core/stores/terminalDrawer';
|
||||||
import { getAdbInstance } from '$lib/core/adb/adb';
|
import { getAdbInstance } from '$lib/core/adb/adb';
|
||||||
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import {
|
import {
|
||||||
initTerminalSession,
|
initTerminalSession,
|
||||||
reinitTerminalSession,
|
reinitTerminalSession,
|
||||||
|
|
@ -270,17 +271,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check connection status periodically
|
// Subscribe to the shared connection store instead of polling every 2s.
|
||||||
let _connInterval: ReturnType<typeof setInterval> | undefined;
|
// The store is updated by adb.ts whenever a device connects or disconnects.
|
||||||
|
let _unsubConnStatus: (() => void) | undefined;
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
_connInterval = setInterval(() => {
|
_unsubConnStatus = adbConnectionStatus.subscribe((status) => {
|
||||||
const instance = getAdbInstance();
|
connStatus = status === 'connected' ? 'connected' : 'disconnected';
|
||||||
connStatus = instance ? 'connected' : 'disconnected';
|
});
|
||||||
}, 2000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (_connInterval) clearInterval(_connInterval);
|
_unsubConnStatus?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,24 +114,19 @@
|
||||||
// -1: unknown command or failed
|
// -1: unknown command or failed
|
||||||
// 0: ok
|
// 0: ok
|
||||||
async function handleNonAdbCmd(cmd: string) {
|
async function handleNonAdbCmd(cmd: string) {
|
||||||
|
// SECURITY: eval() removed — arbitrary code execution risk.
|
||||||
|
// Use explicit commands only (clear, help, etc.).
|
||||||
if (cmd.trim().startsWith('eval')) {
|
if (cmd.trim().startsWith('eval')) {
|
||||||
let eval_statement = cmd.replace('eval', '').trim();
|
terminal?.writeln('eval is disabled for security. Use explicit commands only.');
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
switch (cmd) {
|
switch (cmd.trim()) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
terminal?.clear();
|
terminal?.clear();
|
||||||
return 0;
|
return 0;
|
||||||
|
case 'help':
|
||||||
|
terminal?.writeln('Available commands: clear, help');
|
||||||
|
return 0;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,60 @@ import { adbWriter } from '../stores/adbWriter';
|
||||||
import { WritableStream } from '@yume-chan/stream-extra';
|
import { WritableStream } from '@yume-chan/stream-extra';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
import { adbConnectionStatus } from '../stores/adbConnectionStore';
|
||||||
|
|
||||||
|
let _connectionPromise: Promise<boolean> | null = null;
|
||||||
let syncConnection: any = 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<boolean> {
|
||||||
|
// 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<unknown> = Promise.resolve();
|
let syncOperation: Promise<unknown> = Promise.resolve();
|
||||||
let recipeMenuAdbConnectPromise: Promise<Adb | undefined> | null = null;
|
let recipeMenuAdbConnectPromise: Promise<Adb | undefined> | null = null;
|
||||||
let recipeMenuAndroidServerConnectPromise: Promise<void> | null = null;
|
let recipeMenuAndroidServerConnectPromise: Promise<void> | null = null;
|
||||||
|
|
@ -138,6 +190,7 @@ async function connectWithRetry<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
||||||
|
adbConnectionStatus.setConnecting();
|
||||||
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
||||||
logger.info('usb ok', globalThis.navigator.usb);
|
logger.info('usb ok', globalThis.navigator.usb);
|
||||||
if (device) {
|
if (device) {
|
||||||
|
|
@ -180,6 +233,7 @@ export async function connectDeviceByCred(
|
||||||
credStore: AdbWebCredentialStore,
|
credStore: AdbWebCredentialStore,
|
||||||
connectAndroidServer = true
|
connectAndroidServer = true
|
||||||
) {
|
) {
|
||||||
|
adbConnectionStatus.setConnecting();
|
||||||
try {
|
try {
|
||||||
const connection = await device.connect();
|
const connection = await device.connect();
|
||||||
const transport = await AdbDaemonTransport.authenticate({
|
const transport = await AdbDaemonTransport.authenticate({
|
||||||
|
|
@ -197,6 +251,7 @@ export async function connectDeviceByCred(
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
adbConnectionStatus.setDisconnected();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +259,11 @@ export async function connectDeviceByCred(
|
||||||
export async function saveAdbInstance(adb: Adb | undefined) {
|
export async function saveAdbInstance(adb: Adb | undefined) {
|
||||||
await cleanupSync();
|
await cleanupSync();
|
||||||
AdbInstance.instance = adb;
|
AdbInstance.instance = adb;
|
||||||
|
if (adb) {
|
||||||
|
adbConnectionStatus.setConnected();
|
||||||
|
} else {
|
||||||
|
adbConnectionStatus.setDisconnected();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdbInstance() {
|
export function getAdbInstance() {
|
||||||
|
|
@ -211,6 +271,7 @@ export function getAdbInstance() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectRecipeMenuViaWebUSB() {
|
export async function connectRecipeMenuViaWebUSB() {
|
||||||
|
adbConnectionStatus.setConnecting();
|
||||||
const currentInstance = getAdbInstance();
|
const currentInstance = getAdbInstance();
|
||||||
if (currentInstance) {
|
if (currentInstance) {
|
||||||
await connectToAndroidRecipeMenuServer();
|
await connectToAndroidRecipeMenuServer();
|
||||||
|
|
@ -250,6 +311,7 @@ export async function connectRecipeMenuDeviceByCred(
|
||||||
device: AdbDaemonWebUsbDevice,
|
device: AdbDaemonWebUsbDevice,
|
||||||
credStore: AdbWebCredentialStore
|
credStore: AdbWebCredentialStore
|
||||||
) {
|
) {
|
||||||
|
adbConnectionStatus.setConnecting();
|
||||||
const currentInstance = getAdbInstance();
|
const currentInstance = getAdbInstance();
|
||||||
if (currentInstance) {
|
if (currentInstance) {
|
||||||
await connectToAndroidRecipeMenuServer();
|
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) {
|
export async function executeCmd(command: string) {
|
||||||
|
command = sanitizeAdbCommand(command);
|
||||||
let instance = getAdbInstance();
|
let instance = getAdbInstance();
|
||||||
|
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
|
|
@ -378,6 +455,7 @@ export async function executeStreamingCmd(
|
||||||
onExit?: (exitCode: number | undefined) => void;
|
onExit?: (exitCode: number | undefined) => void;
|
||||||
}
|
}
|
||||||
): Promise<() => void> {
|
): Promise<() => void> {
|
||||||
|
command = sanitizeAdbCommand(command);
|
||||||
const instance = getAdbInstance();
|
const instance = getAdbInstance();
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
|
|
@ -637,14 +715,15 @@ async function connectToAndroidServer(maxRetries = 5) {
|
||||||
if (isRecoverableError(e)) {
|
if (isRecoverableError(e)) {
|
||||||
void connectToAndroidServer();
|
void connectToAndroidServer();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
adbWriter.set(null);
|
adbWriter.set(null);
|
||||||
addNotification('WARN:Brewing Mode T Offline ...');
|
addNotification('WARN:Brewing Mode T Offline ...');
|
||||||
}
|
reader.cancel().catch(() => {});
|
||||||
})();
|
}
|
||||||
return;
|
})();
|
||||||
} else {
|
return;
|
||||||
addNotification('WARN:Brewing Mode T unavailable');
|
} else {
|
||||||
|
addNotification('WARN:Brewing Mode T unavailable');
|
||||||
|
|
||||||
if (attempt < maxRetries - 1) {
|
if (attempt < maxRetries - 1) {
|
||||||
const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000);
|
const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000);
|
||||||
|
|
@ -752,6 +831,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
||||||
} finally {
|
} finally {
|
||||||
adbWriter.set(null);
|
adbWriter.set(null);
|
||||||
addNotification('WARN:Android recipe menu channel offline ...');
|
addNotification('WARN:Android recipe menu channel offline ...');
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
if (retryOnFailure) {
|
if (retryOnFailure) {
|
||||||
scheduleRecipeMenuAndroidServerReconnect();
|
scheduleRecipeMenuAndroidServerReconnect();
|
||||||
}
|
}
|
||||||
|
|
@ -773,3 +853,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
||||||
export function getScrcpyBinaryFromSource() {
|
export function getScrcpyBinaryFromSource() {
|
||||||
//https://github.com/Genymobile/scrcpy/releases
|
//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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import { doc, getDoc } from 'firebase/firestore';
|
// Domain blocker disabled — authentication rework is in progress.
|
||||||
import { db } from '../client/firebase';
|
// The domain changed from Google to others; this module will be rewritten.
|
||||||
|
export async function checkAllowAccess(_userDomain: string): Promise<boolean> {
|
||||||
export async function checkAllowAccess(userDomain: string): Promise<boolean> {
|
|
||||||
const docRef = doc(db, 'whitelist', 'allowedDomains');
|
|
||||||
const snapshot = await getDoc(docRef);
|
|
||||||
|
|
||||||
if (snapshot.exists()) {
|
|
||||||
let domains = snapshot.data();
|
|
||||||
return domains['account_email'].includes(userDomain);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,11 @@ type WSMessage = { type: string; payload: any };
|
||||||
|
|
||||||
// MAXIMUM LIMIT = 1814355
|
// MAXIMUM LIMIT = 1814355
|
||||||
const handlers: Record<string, (payload: any) => void> = {
|
const handlers: Record<string, (payload: any) => 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'),
|
ping: (p) => logger.info('ping from server'),
|
||||||
recipeResponse: (p) => {
|
recipeResponse: (p) => {
|
||||||
let recipe_result = p.result;
|
let recipe_result = p.result;
|
||||||
|
|
|
||||||
54
src/lib/core/stores/adbConnectionStore.ts
Normal file
54
src/lib/core/stores/adbConnectionStore.ts
Normal file
|
|
@ -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<AdbConnectionStatus>('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');
|
||||||
|
|
@ -4,9 +4,14 @@ import { get, writable } from 'svelte/store';
|
||||||
// save notifications to user
|
// save notifications to user
|
||||||
export const notiStore = writable<string[]>([]);
|
export const notiStore = writable<string[]>([]);
|
||||||
|
|
||||||
|
const MAX_NOTI_SIZE = 1000;
|
||||||
|
|
||||||
export function addNotification(msg: string) {
|
export function addNotification(msg: string) {
|
||||||
let current = get(notiStore);
|
let current = get(notiStore);
|
||||||
current.push(msg);
|
current.push(msg);
|
||||||
|
if (current.length > MAX_NOTI_SIZE) {
|
||||||
|
current = current.slice(-MAX_NOTI_SIZE);
|
||||||
|
}
|
||||||
notiStore.set(current);
|
notiStore.set(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,20 @@ import { executeCmd, executeStreamingCmd, getAdbInstance } from '../adb/adb';
|
||||||
import { sendMessage } from '../handlers/ws_messageSender';
|
import { sendMessage } from '../handlers/ws_messageSender';
|
||||||
import { addNotification } from './noti';
|
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
|
* Represents an incoming remote-shell request from the WebSocket server
|
||||||
* asking the client to execute a command on the connected Android device.
|
* asking the client to execute a command on the connected Android device.
|
||||||
|
|
@ -21,6 +35,8 @@ export interface RemoteShellRequest {
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
/** True if the command was aborted by timeout rather than natural completion. */
|
/** True if the command was aborted by timeout rather than natural completion. */
|
||||||
timedOut?: boolean;
|
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<RemoteShellRequest | null>(null);
|
||||||
* prompt the user. Otherwise, immediately executes the command.
|
* prompt the user. Otherwise, immediately executes the command.
|
||||||
*/
|
*/
|
||||||
export function handleIncomingRemoteShell(payload: any) {
|
export function handleIncomingRemoteShell(payload: any) {
|
||||||
const needsConfirm = payload.requireConfirmation === true;
|
let needsConfirm = payload.requireConfirmation === true;
|
||||||
const rawTimeout = payload.timeout;
|
const rawTimeout = payload.timeout;
|
||||||
const timeoutSeconds =
|
const timeoutSeconds =
|
||||||
rawTimeout != null && typeof rawTimeout === 'number' && rawTimeout > 0 ? rawTimeout : undefined;
|
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 = {
|
const req: RemoteShellRequest = {
|
||||||
requestId: payload.generatedRequestId,
|
requestId: payload.generatedRequestId,
|
||||||
commandInput: payload.commandInput,
|
commandInput,
|
||||||
purposeDeclaration: payload.purposeDeclaration,
|
purposeDeclaration: payload.purposeDeclaration || '',
|
||||||
userInfo: payload.userInfo,
|
userInfo: payload.userInfo,
|
||||||
status: needsConfirm ? 'pending' : 'confirmed',
|
status: needsConfirm ? 'pending' : 'confirmed',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
timeoutSeconds
|
timeoutSeconds,
|
||||||
|
dangerous
|
||||||
};
|
};
|
||||||
|
|
||||||
remoteShellRequest.set(req);
|
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) {
|
if (!needsConfirm) {
|
||||||
const suffix = timeoutSeconds ? ` (timeout: ${timeoutSeconds}s)` : '';
|
const suffix = timeoutSeconds ? ` (timeout: ${timeoutSeconds}s)` : '';
|
||||||
addNotification(`INFO:Remote shell command received – executing automatically${suffix}`);
|
addNotification(`INFO:Remote shell command received – executing automatically${suffix}`);
|
||||||
|
|
|
||||||
|
|
@ -176,11 +176,12 @@ export async function connectToWebsocket(id_token?: string) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('error', (e) => {
|
socket.addEventListener('error', (e) => {
|
||||||
// logger.info('WebSocket error: ', e);
|
// logger.info('WebSocket error: ', e);
|
||||||
socketStore.set(null);
|
socketStore.set(null);
|
||||||
sharedKey.set(null);
|
sharedKey.set(null);
|
||||||
});
|
clearInterval(socketCheck);
|
||||||
|
});
|
||||||
} catch (socket_error: any) {
|
} catch (socket_error: any) {
|
||||||
if (ENABLE_WS_DEBUG) {
|
if (ENABLE_WS_DEBUG) {
|
||||||
logger.error('WS_ERR', socket_error);
|
logger.error('WS_ERR', socket_error);
|
||||||
|
|
|
||||||
66
src/lib/server/auth.ts
Normal file
66
src/lib/server/auth.ts
Normal file
|
|
@ -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<VerifiedUser> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { auth } from '$lib/core/stores/auth';
|
import { auth } from '$lib/core/stores/auth';
|
||||||
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
|
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import {
|
import {
|
||||||
|
|
@ -117,7 +118,7 @@
|
||||||
|
|
||||||
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
|
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
|
||||||
adbReconnectTriedForUid = currentUser.uid;
|
adbReconnectTriedForUid = currentUser.uid;
|
||||||
// void tryAutoConnect();
|
void tryAutoConnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
import { permission as currentPermissions } from '$lib/core/stores/permissions';
|
import { permission as currentPermissions } from '$lib/core/stores/permissions';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { referenceFromPage} from '$lib/core/stores/recipeStore';
|
import { referenceFromPage} from '$lib/core/stores/recipeStore';
|
||||||
|
|
||||||
let recipeModBtn = $state<HTMLElement | null>(null);
|
let recipeModBtn = $state<HTMLElement | null>(null);
|
||||||
|
|
@ -23,7 +23,11 @@
|
||||||
|
|
||||||
let perms = $state<string[]>([]);
|
let perms = $state<string[]>([]);
|
||||||
|
|
||||||
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) {
|
if (gotoDashboardBtn && !animationPulseGoto) {
|
||||||
animationPulseGoto = animate(gotoDashboardBtn, {
|
animationPulseGoto = animate(gotoDashboardBtn, {
|
||||||
opacity: [0.8, 1], // Slight pulse in opacity
|
opacity: [0.8, 1], // Slight pulse in opacity
|
||||||
|
|
@ -37,6 +41,10 @@
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearInterval(_pulseInterval);
|
||||||
|
});
|
||||||
|
|
||||||
// let perms = get(currentPermissions);
|
// let perms = get(currentPermissions);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||||
import type { Material } from '$lib/models/material.model';
|
import type { Material } from '$lib/models/material.model';
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
|
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
|
||||||
import {
|
import {
|
||||||
|
|
@ -72,7 +73,7 @@
|
||||||
|
|
||||||
// clear out event
|
// clear out event
|
||||||
|
|
||||||
GlobalEventBus.on('recipe-event', (d: any) => {
|
const unsubRecipeEvent = GlobalEventBus.on('recipe-event', (d: any) => {
|
||||||
logger.info('[recipe-ev] get event: ', d);
|
logger.info('[recipe-ev] get event: ', d);
|
||||||
if (d?.type == 'load-recipe' && d?.status == 'end') {
|
if (d?.type == 'load-recipe' && d?.status == 'end') {
|
||||||
addNotification('INFO:Get data, waiting for reloading ...');
|
addNotification('INFO:Get data, waiting for reloading ...');
|
||||||
|
|
@ -829,6 +830,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
unsubRecipeEvent();
|
||||||
clearOnMenuSavedCallback();
|
clearOnMenuSavedCallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
@ -295,21 +296,12 @@
|
||||||
|
|
||||||
async function connectAdb() {
|
async function connectAdb() {
|
||||||
try {
|
try {
|
||||||
if (adb.getAdbInstance()) {
|
const connected = await adb.ensureAdbConnection();
|
||||||
|
if (connected) {
|
||||||
if (!isAdbWriterAvailable()) {
|
if (!isAdbWriterAvailable()) {
|
||||||
await adb.reconnectAndroidRecipeMenuServer();
|
await adb.reconnectAndroidRecipeMenuServer();
|
||||||
}
|
}
|
||||||
await loadRecipeFromMachine();
|
await loadRecipeFromMachine();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('usb' in navigator)) {
|
|
||||||
throw new Error('WebUSB not supported');
|
|
||||||
}
|
|
||||||
|
|
||||||
await adb.connectRecipeMenuViaWebUSB();
|
|
||||||
if (adb.getAdbInstance()) {
|
|
||||||
await loadRecipeFromMachine();
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addNotification(`ERROR:${e}`);
|
addNotification(`ERROR:${e}`);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,16 @@ import { logger } from '$lib/core/utils/logger';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { env } from '$env/dynamic/public';
|
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.
|
// Method 2: forward a machine-generated sync_1.file to the adv FTP server.
|
||||||
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
|
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
|
// Verify authentication
|
||||||
|
await verifyAuthToken(request);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
const country = formData.get('country') as string;
|
const country = formData.get('country') as string;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,21 @@ import { logger } from '$lib/core/utils/logger';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { env } from '$env/dynamic/public';
|
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.
|
// Adv videos are served by the same taobin_image service as menu images.
|
||||||
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
|
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 }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
|
// Verify authentication
|
||||||
|
await verifyAuthToken(request);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
const country = formData.get('country') as string;
|
const country = formData.get('country') as string;
|
||||||
|
|
@ -22,6 +31,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
throw error(400, 'Missing required fields');
|
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 =
|
const endpoint =
|
||||||
`${ADV_API_BASE}/adv/upload/${encodeURIComponent(country)}/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}` +
|
`${ADV_API_BASE}/adv/upload/${encodeURIComponent(country)}/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}` +
|
||||||
`?regenerate=${encodeURIComponent(regenerate)}`;
|
`?regenerate=${encodeURIComponent(regenerate)}`;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,17 @@ import { logger } from '$lib/core/utils/logger';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { verifyAuthToken } from '$lib/server/auth';
|
||||||
|
|
||||||
const IMAGE_API_BASE = env.PUBLIC_POST_IMAGE;
|
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 }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
|
// Verify authentication
|
||||||
|
await verifyAuthToken(request);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
const country = formData.get('country') as string;
|
const country = formData.get('country') as string;
|
||||||
|
|
@ -19,6 +25,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
if (!folder || !uid || !displayName || !email || !file) {
|
if (!folder || !uid || !displayName || !email || !file) {
|
||||||
throw error(400, 'Missing required fields');
|
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
|
// Build the upload endpoint
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,50 @@
|
||||||
import { logger } from '$lib/core/utils/logger';
|
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
|
// In-memory store for streamed catalog data
|
||||||
// Format: { batchId: { chunks: [...], status: 'collecting'|'complete'|'error' } }
|
// Format: { batchId: { chunks: [...], status: 'collecting'|'complete'|'error' } }
|
||||||
const streamCache = new Map<string, any>();
|
const streamCache = new Map<string, any>();
|
||||||
|
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 {
|
try {
|
||||||
|
await verifyAuthToken(request);
|
||||||
|
|
||||||
const data = await request.json();
|
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
|
// Initialize or update batch
|
||||||
if (!streamCache.has(batch_id)) {
|
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, {
|
streamCache.set(batch_id, {
|
||||||
chunks: [],
|
chunks: [],
|
||||||
status: 'collecting',
|
status: 'collecting',
|
||||||
total_chunks,
|
total_chunks: cappedTotal,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +55,10 @@ export async function POST({ request }) {
|
||||||
if (msg === 'start') {
|
if (msg === 'start') {
|
||||||
logger.info(`[API] Stream started for batch ${batch_id}`);
|
logger.info(`[API] Stream started for batch ${batch_id}`);
|
||||||
} else if (msg === 'chunk') {
|
} 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);
|
batch.chunks.push(content);
|
||||||
logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
|
logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
|
||||||
} else if (msg === 'end') {
|
} 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');
|
const batchId = url.searchParams.get('batch_id');
|
||||||
|
|
||||||
// Clean up old cache entries (older than 5 minutes)
|
// Clean up old cache entries (older than 5 minutes)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue