#!/bin/sh # SPDX-License-Identifier: Apache-2.0 # LuCI RPC backend for Streamlit Platform # Copyright (C) 2025 CyberMind.fr . /lib/functions.sh . /usr/share/libubox/jshn.sh # Extract ZIP with flatten for single root directories extract_zip_flatten() { local zip_file="$1" local target_dir="$2" local tmpextract="/tmp/streamlit_extract_$$" mkdir -p "$tmpextract" "$target_dir" unzip -o "$zip_file" -d "$tmpextract" >/dev/null 2>&1 local root_items=$(ls -1 "$tmpextract" 2>/dev/null | wc -l) if [ "$root_items" = "1" ]; then local single_dir="$tmpextract/$(ls -1 "$tmpextract" | head -1)" if [ -d "$single_dir" ]; then mv "$single_dir"/* "$target_dir/" 2>/dev/null mv "$single_dir"/.* "$target_dir/" 2>/dev/null else mv "$single_dir" "$target_dir/" fi else mv "$tmpextract"/* "$target_dir/" 2>/dev/null mv "$tmpextract"/.* "$target_dir/" 2>/dev/null fi rm -rf "$tmpextract" } CONFIG="streamlit" LXC_NAME="streamlit" LXC_PATH="/srv/lxc" APPS_PATH="/srv/streamlit/apps" # JSON helpers json_init_obj() { json_init; json_add_object "result"; } json_close_obj() { json_close_object; json_dump; } json_error() { json_init_obj json_add_boolean "success" 0 json_add_string "message" "$1" json_close_obj } json_success() { json_init_obj json_add_boolean "success" 1 [ -n "$1" ] && json_add_string "message" "$1" json_close_obj } # Check if container is running lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" } # Check if container exists lxc_exists() { [ -f "$LXC_PATH/$LXC_NAME/config" ] && [ -d "$LXC_PATH/$LXC_NAME/rootfs" ] } # Get service status get_status() { local enabled running installed uptime local http_port data_path memory_limit active_app config_load "$CONFIG" config_get enabled main enabled "0" config_get http_port main http_port "8501" config_get data_path main data_path "/srv/streamlit" config_get memory_limit main memory_limit "512M" config_get active_app main active_app "hello" running="false" installed="false" uptime="" if lxc_exists; then installed="true" fi if lxc_running; then running="true" uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1 | awk '{print $3}') fi # Count apps local app_count=0 APPS_PATH="$data_path/apps" if [ -d "$APPS_PATH" ]; then app_count=$(ls -1 "$APPS_PATH"/*.py 2>/dev/null | wc -l) fi # Get LAN IP for URL local lan_ip lan_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") json_init_obj json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" json_add_boolean "running" "$( [ "$running" = "true" ] && echo 1 || echo 0 )" json_add_boolean "installed" "$( [ "$installed" = "true" ] && echo 1 || echo 0 )" json_add_string "uptime" "$uptime" json_add_int "http_port" "$http_port" json_add_string "data_path" "$data_path" json_add_string "memory_limit" "$memory_limit" json_add_string "active_app" "$active_app" json_add_int "app_count" "$app_count" json_add_string "web_url" "http://${lan_ip}:${http_port}" json_add_string "container_name" "$LXC_NAME" json_close_obj } # Get configuration get_config() { local http_port http_host data_path memory_limit enabled active_app local headless gather_stats theme_base theme_primary config_load "$CONFIG" # Main settings config_get http_port main http_port "8501" config_get http_host main http_host "0.0.0.0" config_get data_path main data_path "/srv/streamlit" config_get memory_limit main memory_limit "512M" config_get enabled main enabled "0" config_get active_app main active_app "hello" # Server settings config_get headless server headless "true" config_get gather_stats server browser_gather_usage_stats "false" config_get theme_base server theme_base "dark" config_get theme_primary server theme_primary_color "#0ff" json_init_obj json_add_object "main" json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" json_add_int "http_port" "$http_port" json_add_string "http_host" "$http_host" json_add_string "data_path" "$data_path" json_add_string "memory_limit" "$memory_limit" json_add_string "active_app" "$active_app" json_close_object json_add_object "server" json_add_boolean "headless" "$( [ "$headless" = "true" ] && echo 1 || echo 0 )" json_add_boolean "browser_gather_usage_stats" "$( [ "$gather_stats" = "true" ] && echo 1 || echo 0 )" json_add_string "theme_base" "$theme_base" json_add_string "theme_primary_color" "$theme_primary" json_close_object json_close_obj } # Save configuration save_config() { read -r input local http_port http_host data_path memory_limit enabled active_app local headless gather_stats theme_base theme_primary http_port=$(echo "$input" | jsonfilter -e '@.http_port' 2>/dev/null) http_host=$(echo "$input" | jsonfilter -e '@.http_host' 2>/dev/null) data_path=$(echo "$input" | jsonfilter -e '@.data_path' 2>/dev/null) memory_limit=$(echo "$input" | jsonfilter -e '@.memory_limit' 2>/dev/null) enabled=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null) active_app=$(echo "$input" | jsonfilter -e '@.active_app' 2>/dev/null) headless=$(echo "$input" | jsonfilter -e '@.headless' 2>/dev/null) gather_stats=$(echo "$input" | jsonfilter -e '@.browser_gather_usage_stats' 2>/dev/null) theme_base=$(echo "$input" | jsonfilter -e '@.theme_base' 2>/dev/null) theme_primary=$(echo "$input" | jsonfilter -e '@.theme_primary_color' 2>/dev/null) [ -n "$http_port" ] && uci set "${CONFIG}.main.http_port=$http_port" [ -n "$http_host" ] && uci set "${CONFIG}.main.http_host=$http_host" [ -n "$data_path" ] && uci set "${CONFIG}.main.data_path=$data_path" [ -n "$memory_limit" ] && uci set "${CONFIG}.main.memory_limit=$memory_limit" [ -n "$enabled" ] && uci set "${CONFIG}.main.enabled=$enabled" [ -n "$active_app" ] && uci set "${CONFIG}.main.active_app=$active_app" [ -n "$headless" ] && uci set "${CONFIG}.server.headless=$headless" [ -n "$gather_stats" ] && uci set "${CONFIG}.server.browser_gather_usage_stats=$gather_stats" [ -n "$theme_base" ] && uci set "${CONFIG}.server.theme_base=$theme_base" [ -n "$theme_primary" ] && uci set "${CONFIG}.server.theme_primary_color=$theme_primary" uci commit "$CONFIG" json_success "Configuration saved" } # Start service start_service() { if lxc_running; then json_error "Service is already running" return fi if ! lxc_exists; then json_error "Container not installed. Run install first." return fi /etc/init.d/streamlit start >/dev/null 2>&1 & sleep 2 if lxc_running; then json_success "Service started" else json_error "Failed to start service" fi } # Stop service stop_service() { if ! lxc_running; then json_error "Service is not running" return fi /etc/init.d/streamlit stop >/dev/null 2>&1 sleep 2 if ! lxc_running; then json_success "Service stopped" else json_error "Failed to stop service" fi } # Restart service restart_service() { /etc/init.d/streamlit restart >/dev/null 2>&1 & sleep 3 if lxc_running; then json_success "Service restarted" else json_error "Service restart failed" fi } # Install Streamlit install() { if lxc_exists; then json_error "Already installed. Use update to refresh." return fi # Run install in background /usr/sbin/streamlitctl install >/var/log/streamlit-install.log 2>&1 & json_init_obj json_add_boolean "started" 1 json_add_string "message" "Installation started in background" json_add_string "log_file" "/var/log/streamlit-install.log" json_close_obj } # Uninstall Streamlit uninstall() { /usr/sbin/streamlitctl uninstall >/dev/null 2>&1 if ! lxc_exists; then json_success "Uninstalled successfully" else json_error "Uninstall failed" fi } # Update Streamlit update() { if ! lxc_exists; then json_error "Not installed. Run install first." return fi # Run update in background /usr/sbin/streamlitctl update >/var/log/streamlit-update.log 2>&1 & json_init_obj json_add_boolean "started" 1 json_add_string "message" "Update started in background" json_add_string "log_file" "/var/log/streamlit-update.log" json_close_obj } # Get logs get_logs() { read -r input local lines lines=$(echo "$input" | jsonfilter -e '@.lines' 2>/dev/null) [ -z "$lines" ] && lines=100 local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" json_init_obj json_add_array "logs" # Get container logs from data path if [ -d "$data_path/logs" ]; then local logfile for logfile in "$data_path/logs"/*.log; do [ -f "$logfile" ] || continue tail -n "$lines" "$logfile" 2>/dev/null | while IFS= read -r line; do json_add_string "" "$line" done done fi # Also check install/update logs for logfile in /var/log/streamlit-install.log /var/log/streamlit-update.log; do [ -f "$logfile" ] || continue tail -n 50 "$logfile" 2>/dev/null | while IFS= read -r line; do json_add_string "" "$line" done done json_close_array json_close_obj } # List apps list_apps() { local data_path active_app config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" config_get active_app main active_app "hello" APPS_PATH="$data_path/apps" local seen="" json_init_obj json_add_array "apps" if [ -d "$APPS_PATH" ]; then # Scan top-level .py files for app in "$APPS_PATH"/*.py; do [ -f "$app" ] || continue local name=$(basename "$app" .py) local display_name=$(uci -q get "${CONFIG}.${name}.name") [ -z "$display_name" ] && display_name="$name" local size=$(ls -la "$app" 2>/dev/null | awk '{print $5}') local mtime=$(stat -c %Y "$app" 2>/dev/null || echo "0") local is_active=0 [ "$name" = "$active_app" ] && is_active=1 seen="$seen $name " json_add_object "" json_add_string "id" "$name" json_add_string "name" "$display_name" json_add_string "path" "$app" json_add_string "size" "$size" json_add_int "mtime" "$mtime" json_add_boolean "active" "$is_active" json_close_object done # Scan subdirectories (ZIP-uploaded apps) for dir in "$APPS_PATH"/*/; do [ -d "$dir" ] || continue local dirname=$(basename "$dir") # Skip Streamlit multi-page convention dir and hidden dirs case "$dirname" in pages|.*) continue ;; esac # Skip if already seen as a top-level .py case "$seen" in *" $dirname "*) continue ;; esac # Prefer app.py as main entry point, fall back to first .py local main_py="" [ -f "$dir/app.py" ] && main_py="$dir/app.py" [ -z "$main_py" ] && main_py=$(find "$dir" -maxdepth 1 -name "*.py" -type f | head -1) [ -z "$main_py" ] && main_py=$(find "$dir" -maxdepth 2 -name "*.py" -type f | head -1) [ -z "$main_py" ] && continue local display_name=$(uci -q get "${CONFIG}.${dirname}.name") [ -z "$display_name" ] && display_name="$dirname" local size=$(stat -c %s "$main_py" 2>/dev/null || echo "0") local mtime=$(stat -c %Y "$main_py" 2>/dev/null || echo "0") local is_active=0 [ "$dirname" = "$active_app" ] && is_active=1 json_add_object "" json_add_string "id" "$dirname" json_add_string "name" "$display_name" json_add_string "path" "$main_py" json_add_string "size" "$size" json_add_int "mtime" "$mtime" json_add_boolean "active" "$is_active" json_close_object done fi json_close_array json_add_string "active_app" "$active_app" json_add_string "apps_path" "$APPS_PATH" json_close_obj } # Add app add_app() { read -r input local name path name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) path=$(echo "$input" | jsonfilter -e '@.path' 2>/dev/null) if [ -z "$name" ] || [ -z "$path" ]; then json_error "Missing name or path" return fi # Sanitize name for UCI name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.path=$path" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" json_success "App added: $name" } # Remove app remove_app() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" # Remove app files (top-level .py or subdirectory) if [ -f "$data_path/apps/${name}.py" ]; then rm -f "$data_path/apps/${name}.py" rm -f "$data_path/apps/${name}.requirements.txt" fi if [ -d "$data_path/apps/${name}" ]; then rm -rf "$data_path/apps/${name}" fi # Remove UCI config uci -q delete "${CONFIG}.${name}" uci commit "$CONFIG" json_success "App removed: $name" } # Set active app set_active_app() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi uci set "${CONFIG}.main.active_app=$name" uci commit "$CONFIG" if [ $? -eq 0 ]; then json_success "Active app set to: $name" else json_error "Failed to set active app" fi } # Get app details get_app() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi local data_path active_app config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" config_get active_app main active_app "hello" local app_file="$data_path/apps/${name}.py" if [ ! -f "$app_file" ]; then json_error "App not found" return fi local size=$(ls -la "$app_file" 2>/dev/null | awk '{print $5}') local mtime=$(stat -c %Y "$app_file" 2>/dev/null || echo "0") local lines=$(wc -l < "$app_file" 2>/dev/null || echo "0") local is_active=0 [ "$name" = "$active_app" ] && is_active=1 json_init_obj json_add_string "name" "$name" json_add_string "path" "$app_file" json_add_string "size" "$size" json_add_int "mtime" "$mtime" json_add_int "lines" "$lines" json_add_boolean "active" "$is_active" json_close_obj } # Upload app (receive base64 content) # NOTE: uhttpd-mod-ubus has a 64KB JSON body limit. # Small files (<40KB) go through RPC directly. # Larger files use chunked upload: upload_chunk + upload_finalize. upload_app() { local tmpinput="/tmp/rpcd_upload_$$.json" cat > "$tmpinput" local name name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') if [ -z "$name" ]; then rm -f "$tmpinput" json_error "Missing name" return fi local b64file="/tmp/rpcd_b64_$$.txt" jsonfilter -i "$tmpinput" -e '@.content' > "$b64file" 2>/dev/null rm -f "$tmpinput" if [ ! -s "$b64file" ]; then rm -f "$b64file" json_error "Missing content" return fi local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" local app_file="$data_path/apps/${name}.py" mkdir -p "$data_path/apps" base64 -d < "$b64file" > "$app_file" 2>/dev/null local rc=$? rm -f "$b64file" if [ $rc -eq 0 ] && [ -s "$app_file" ]; then uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.path=${name}.py" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" # Auto-create Gitea repo and push (background) streamlitctl gitea push "$name" >/dev/null 2>&1 & json_success "App uploaded: $name" else rm -f "$app_file" json_error "Failed to decode app content" fi } # Chunked upload: receive a base64 chunk and append to temp file upload_chunk() { local tmpinput="/tmp/rpcd_chunk_$$.json" cat > "$tmpinput" local name chunk_data chunk_index name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) chunk_data=$(jsonfilter -i "$tmpinput" -e '@.data' 2>/dev/null) chunk_index=$(jsonfilter -i "$tmpinput" -e '@.index' 2>/dev/null) rm -f "$tmpinput" name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') if [ -z "$name" ] || [ -z "$chunk_data" ]; then json_error "Missing name or data" return fi local staging="/tmp/streamlit_upload_${name}.b64" # First chunk: create new file; subsequent: append if [ "$chunk_index" = "0" ]; then printf '%s' "$chunk_data" > "$staging" else printf '%s' "$chunk_data" >> "$staging" fi json_success "Chunk $chunk_index received" } # Finalize chunked upload: decode accumulated base64 and save upload_finalize() { local tmpinput="/tmp/rpcd_finalize_$$.json" cat > "$tmpinput" local name is_zip name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) is_zip=$(jsonfilter -i "$tmpinput" -e '@.is_zip' 2>/dev/null) rm -f "$tmpinput" name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') if [ -z "$name" ]; then json_error "Missing name" return fi local staging="/tmp/streamlit_upload_${name}.b64" if [ ! -s "$staging" ]; then json_error "No upload data found for $name" return fi local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" mkdir -p "$data_path/apps" if [ "$is_zip" = "1" ] || [ "$is_zip" = "true" ]; then # Decode as ZIP and extract local tmpzip="/tmp/upload_${name}_$$.zip" base64 -d < "$staging" > "$tmpzip" 2>/dev/null rm -f "$staging" if [ ! -s "$tmpzip" ]; then rm -f "$tmpzip" json_error "Failed to decode ZIP" return fi local app_dir="$data_path/apps/$name" mkdir -p "$app_dir" extract_zip_flatten "$tmpzip" "$app_dir" rm -f "$tmpzip" local main_py main_py=$(find "$app_dir" -maxdepth 2 -name "*.py" -type f | head -1) if [ -n "$main_py" ]; then # Install requirements if found (requirements.txt or requirements*.txt) if lxc_running; then local req_file="" if [ -f "$app_dir/requirements.txt" ]; then req_file="requirements.txt" else req_file=$(ls -1 "$app_dir"/requirements*.txt 2>/dev/null | head -1 | xargs basename 2>/dev/null) fi if [ -n "$req_file" ]; then lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/${name}/${req_file}" >/dev/null 2>&1 & fi fi uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.path=$main_py" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" # Auto-push to Gitea if configured (background) streamlitctl gitea push "$name" >/dev/null 2>&1 & json_success "ZIP app deployed: $name" else json_error "No Python files found in archive" fi else # Decode as .py file local app_file="$data_path/apps/${name}.py" base64 -d < "$staging" > "$app_file" 2>/dev/null local rc=$? rm -f "$staging" if [ $rc -eq 0 ] && [ -s "$app_file" ]; then uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.path=${name}.py" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" # Auto-push to Gitea if configured (background) streamlitctl gitea push "$name" >/dev/null 2>&1 & json_success "App uploaded: $name" else rm -f "$app_file" json_error "Failed to decode app content" fi fi } # List instances list_instances() { json_init_obj json_add_array "instances" config_load "$CONFIG" _add_instance_json() { local section="$1" local name app port enabled autostart inst_name config_get inst_name "$section" name "" config_get app "$section" app "" config_get port "$section" port "" config_get enabled "$section" enabled "0" config_get autostart "$section" autostart "0" [ -z "$app" ] && return json_add_object "" json_add_string "id" "$section" json_add_string "name" "$inst_name" json_add_string "app" "$app" json_add_int "port" "$port" json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" json_add_boolean "autostart" "$( [ "$autostart" = "1" ] && echo 1 || echo 0 )" json_close_object } config_foreach _add_instance_json instance json_close_array json_close_obj } # Add instance add_instance() { read -r input local id name app port id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) app=$(echo "$input" | jsonfilter -e '@.app' 2>/dev/null) port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null) if [ -z "$id" ] || [ -z "$app" ] || [ -z "$port" ]; then json_error "Missing id, app, or port" return fi [ -z "$name" ] && name="$id" # Validate port number if ! echo "$port" | grep -qE '^[0-9]+$'; then json_error "Invalid port number" return fi # Check if instance already exists local existing existing=$(uci -q get "${CONFIG}.${id}") if [ -n "$existing" ]; then json_error "Instance $id already exists" return fi uci set "${CONFIG}.${id}=instance" uci set "${CONFIG}.${id}.name=$name" uci set "${CONFIG}.${id}.app=$app" uci set "${CONFIG}.${id}.port=$port" uci set "${CONFIG}.${id}.enabled=1" uci set "${CONFIG}.${id}.autostart=1" uci commit "$CONFIG" json_success "Instance added: $id" } # Remove instance remove_instance() { read -r input local id id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) if [ -z "$id" ]; then json_error "Missing instance id" return fi # Check if instance exists local existing existing=$(uci -q get "${CONFIG}.${id}") if [ -z "$existing" ]; then json_error "Instance $id not found" return fi uci delete "${CONFIG}.${id}" uci commit "$CONFIG" json_success "Instance removed: $id" } # Rename app rename_app() { read -r input local id name id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$id" ] || [ -z "$name" ]; then json_error "Missing id or name" return fi # Create UCI section if it doesn't exist yet local existing existing=$(uci -q get "${CONFIG}.${id}") if [ -z "$existing" ]; then uci set "${CONFIG}.${id}=app" uci set "${CONFIG}.${id}.enabled=1" fi uci set "${CONFIG}.${id}.name=$name" uci commit "$CONFIG" json_success "App renamed" } # Rename instance rename_instance() { read -r input local id name id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$id" ] || [ -z "$name" ]; then json_error "Missing id or name" return fi local existing existing=$(uci -q get "${CONFIG}.${id}") if [ -z "$existing" ]; then json_error "Instance $id not found" return fi uci set "${CONFIG}.${id}.name=$name" uci commit "$CONFIG" json_success "Instance renamed" } # Enable instance enable_instance() { read -r input local id id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) if [ -z "$id" ]; then json_error "Missing instance id" return fi uci set "${CONFIG}.${id}.enabled=1" uci commit "$CONFIG" json_success "Instance enabled: $id" } # Disable instance disable_instance() { read -r input local id id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) if [ -z "$id" ]; then json_error "Missing instance id" return fi uci set "${CONFIG}.${id}.enabled=0" uci commit "$CONFIG" json_success "Instance disabled: $id" } # Preview ZIP contents preview_zip() { # Write stdin to temp file to avoid shell variable size limits local tmpinput="/tmp/rpcd_preview_$$.json" cat > "$tmpinput" local tmpzip="/tmp/preview_$$.zip" jsonfilter -i "$tmpinput" -e '@.content' 2>/dev/null | base64 -d > "$tmpzip" 2>/dev/null rm -f "$tmpinput" if [ ! -s "$tmpzip" ]; then rm -f "$tmpzip" json_error "Failed to decode ZIP" return fi json_init_obj json_add_array "files" # Use unzip to list contents unzip -l "$tmpzip" 2>/dev/null | tail -n +4 | head -n -2 | while read -r size date time name; do [ -z "$name" ] && continue local is_dir=0 echo "$name" | grep -q '/$' && is_dir=1 json_add_object "" json_add_string "path" "$name" json_add_int "size" "$size" json_add_boolean "is_dir" "$is_dir" json_close_object done json_close_array json_close_obj rm -f "$tmpzip" } # Upload ZIP with selected files upload_zip() { # Write stdin to temp file to avoid shell variable size limits local tmpinput="/tmp/rpcd_zipinput_$$.json" cat > "$tmpinput" local name selected_files name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) selected_files=$(jsonfilter -i "$tmpinput" -e '@.selected_files' 2>/dev/null) # Sanitize name for UCI compatibility (alphanumeric and underscores only) name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') if [ -z "$name" ]; then rm -f "$tmpinput" json_error "Missing name" return fi local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" local app_dir="$data_path/apps/$name" local tmpzip="/tmp/upload_$$.zip" # Extract base64 content and decode directly to zip file jsonfilter -i "$tmpinput" -e '@.content' 2>/dev/null | base64 -d > "$tmpzip" 2>/dev/null rm -f "$tmpinput" if [ ! -s "$tmpzip" ]; then rm -f "$tmpzip" json_error "Failed to decode ZIP" return fi mkdir -p "$app_dir" # Extract selected files or all if none specified local file_count=$(echo "$selected_files" | jsonfilter -e '@[*]' 2>/dev/null | wc -l) if [ -n "$selected_files" ] && [ "$file_count" -gt 0 ] 2>/dev/null; then # Parse selected files array and extract each echo "$selected_files" | jsonfilter -e '@[*]' 2>/dev/null | while read -r filepath; do [ -z "$filepath" ] && continue unzip -o "$tmpzip" "$filepath" -d "$app_dir" >/dev/null 2>&1 done else # Extract all extract_zip_flatten "$tmpzip" "$app_dir" fi rm -f "$tmpzip" # Find main .py file for registration local main_py main_py=$(find "$app_dir" -maxdepth 2 -name "*.py" -type f | head -1) if [ -n "$main_py" ]; then # Install requirements if found (requirements.txt or requirements*.txt) if lxc_running; then local req_file="" if [ -f "$app_dir/requirements.txt" ]; then req_file="requirements.txt" else req_file=$(ls -1 "$app_dir"/requirements*.txt 2>/dev/null | head -1 | xargs basename 2>/dev/null) fi if [ -n "$req_file" ]; then lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/${name}/${req_file}" >/dev/null 2>&1 & fi fi # Register in UCI uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.path=$main_py" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" # Auto-create Gitea repo and push (background) streamlitctl gitea push "$name" >/dev/null 2>&1 & json_init_obj json_add_boolean "success" 1 json_add_string "message" "App deployed: $name" json_add_string "path" "$app_dir" json_add_string "main_file" "$main_py" json_close_obj else json_error "No Python files found in extracted archive" fi } # Get Gitea config get_gitea_config() { config_load "$CONFIG" local enabled url user token config_get enabled gitea enabled "0" config_get url gitea url "" config_get user gitea user "" config_get token gitea token "" json_init_obj json_add_boolean "enabled" "$( [ "$enabled" = "1" ] && echo 1 || echo 0 )" json_add_string "url" "$url" json_add_string "user" "$user" json_add_boolean "has_token" "$( [ -n "$token" ] && echo 1 || echo 0 )" json_close_obj } # Save Gitea config save_gitea_config() { read -r input local enabled url user token enabled=$(echo "$input" | jsonfilter -e '@.enabled' 2>/dev/null) url=$(echo "$input" | jsonfilter -e '@.url' 2>/dev/null) user=$(echo "$input" | jsonfilter -e '@.user' 2>/dev/null) token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null) # Ensure gitea section exists uci -q get "${CONFIG}.gitea" >/dev/null || uci set "${CONFIG}.gitea=gitea" [ -n "$enabled" ] && uci set "${CONFIG}.gitea.enabled=$enabled" [ -n "$url" ] && uci set "${CONFIG}.gitea.url=$url" [ -n "$user" ] && uci set "${CONFIG}.gitea.user=$user" [ -n "$token" ] && uci set "${CONFIG}.gitea.token=$token" uci commit "$CONFIG" json_success "Gitea configuration saved" } # Clone app from Gitea gitea_clone() { read -r input local name repo name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) repo=$(echo "$input" | jsonfilter -e '@.repo' 2>/dev/null) if [ -z "$name" ] || [ -z "$repo" ]; then json_error "Missing name or repo" return fi # Run clone in background /usr/sbin/streamlitctl gitea clone "$name" "$repo" >/var/log/streamlit-gitea.log 2>&1 & json_init_obj json_add_boolean "success" 1 json_add_string "message" "Cloning $repo to $name in background" json_close_obj } # Pull app from Gitea gitea_pull() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi # Run pull /usr/sbin/streamlitctl gitea pull "$name" >/var/log/streamlit-gitea.log 2>&1 if [ $? -eq 0 ]; then json_success "App updated from Gitea: $name" else json_error "Failed to pull app from Gitea" fi } # List Gitea repositories gitea_list_repos() { config_load "$CONFIG" local enabled url user token config_get enabled gitea enabled "0" config_get url gitea url "" config_get user gitea user "" config_get token gitea token "" if [ "$enabled" != "1" ] || [ -z "$url" ] || [ -z "$token" ]; then json_error "Gitea not configured" return fi # Call Gitea API to list user repos local api_url="${url}/api/v1/user/repos" local response response=$(curl -s -H "Authorization: token $token" "$api_url" 2>/dev/null) if [ -z "$response" ]; then json_error "Failed to connect to Gitea" return fi json_init_obj json_add_array "repos" # Parse JSON response (simple extraction) echo "$response" | jsonfilter -e '@[*].full_name' 2>/dev/null | while read -r repo; do [ -z "$repo" ] && continue json_add_string "" "$repo" done json_close_array json_close_obj } # Get app source code for editing get_source() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" # Find the app file (either top-level .py or subdirectory with app.py) local app_file="" if [ -f "$data_path/apps/${name}.py" ]; then app_file="$data_path/apps/${name}.py" elif [ -f "$data_path/apps/${name}/app.py" ]; then app_file="$data_path/apps/${name}/app.py" elif [ -d "$data_path/apps/${name}" ]; then app_file=$(find "$data_path/apps/${name}" -maxdepth 2 -name "*.py" -type f | head -1) fi if [ -z "$app_file" ] || [ ! -f "$app_file" ]; then json_error "App source not found" return fi # Build JSON output manually to avoid jshn argument size limits local tmpfile="/tmp/source_output_$$.json" printf '{"result":{"success":true,"name":"%s","path":"%s","content":"' "$name" "$app_file" > "$tmpfile" # Encode source as base64 to handle special characters base64 -w 0 < "$app_file" >> "$tmpfile" printf '"}}\n' >> "$tmpfile" cat "$tmpfile" rm -f "$tmpfile" } # Save edited app source code save_source() { local tmpinput="/tmp/rpcd_save_$$.json" cat > "$tmpinput" local name content name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') if [ -z "$name" ]; then rm -f "$tmpinput" json_error "Missing name" return fi local data_path config_load "$CONFIG" config_get data_path main data_path "/srv/streamlit" # Find the app file local app_file="" if [ -f "$data_path/apps/${name}.py" ]; then app_file="$data_path/apps/${name}.py" elif [ -f "$data_path/apps/${name}/app.py" ]; then app_file="$data_path/apps/${name}/app.py" elif [ -d "$data_path/apps/${name}" ]; then app_file=$(find "$data_path/apps/${name}" -maxdepth 2 -name "*.py" -type f | head -1) fi if [ -z "$app_file" ]; then # New app - create as top-level .py app_file="$data_path/apps/${name}.py" fi # Extract and decode base64 content local b64file="/tmp/rpcd_b64_save_$$.txt" jsonfilter -i "$tmpinput" -e '@.content' > "$b64file" 2>/dev/null rm -f "$tmpinput" if [ ! -s "$b64file" ]; then rm -f "$b64file" json_error "Missing content" return fi # Create backup before overwriting [ -f "$app_file" ] && cp "$app_file" "${app_file}.bak" mkdir -p "$(dirname "$app_file")" base64 -d < "$b64file" > "$app_file" 2>/dev/null local rc=$? rm -f "$b64file" if [ $rc -eq 0 ] && [ -s "$app_file" ]; then # Auto-push to Gitea (background) streamlitctl gitea push "$name" >/dev/null 2>&1 & json_success "Source saved: $name" else # Restore backup on failure [ -f "${app_file}.bak" ] && mv "${app_file}.bak" "$app_file" json_error "Failed to save source" fi } # Emancipate app - KISS ULTIME MODE multi-channel exposure emancipate() { read -r input local name domain name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi # Check if app has an instance with a port config_load "$CONFIG" local port port=$(uci -q get "${CONFIG}.${name}.port") if [ -z "$port" ]; then # Try to find instance with matching app for section in $(uci -q show "$CONFIG" | grep "\.app=" | grep "='${name}'" | cut -d. -f2); do port=$(uci -q get "${CONFIG}.${section}.port") [ -n "$port" ] && break done fi if [ -z "$port" ]; then json_error "No instance found for app. Create an instance first." return fi # Run emancipate in background /usr/sbin/streamlitctl emancipate "$name" "$domain" >/var/log/streamlit-emancipate.log 2>&1 & local pid=$! json_init_obj json_add_boolean "success" 1 json_add_string "message" "Emancipation started for $name" json_add_string "domain" "$domain" json_add_int "port" "$port" json_add_int "pid" "$pid" json_close_obj } # Test uploaded app - validate syntax and imports before finalize test_upload() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) name=$(echo "$name" | sed 's/[^a-zA-Z0-9_]/_/g; s/^_*//; s/_*$//') if [ -z "$name" ]; then json_error "Missing app name" return fi # Check if staging file exists local staging="/tmp/streamlit_upload_${name}.b64" if [ ! -s "$staging" ]; then json_error "No pending upload for $name" return fi # Decode to temp file for testing local tmppy="/tmp/test_upload_${name}.py" base64 -d < "$staging" > "$tmppy" 2>/dev/null if [ ! -s "$tmppy" ]; then rm -f "$tmppy" json_error "Failed to decode upload data" return fi local errors="" local warnings="" local file_size=$(stat -c %s "$tmppy" 2>/dev/null || echo "0") local line_count=$(wc -l < "$tmppy" 2>/dev/null || echo "0") # Check 1: Basic file validation if [ "$file_size" -lt 10 ]; then errors="File too small (${file_size} bytes)" rm -f "$tmppy" json_init_obj json_add_boolean "valid" 0 json_add_string "errors" "$errors" json_close_obj return fi # Check 2: Python syntax validation (inside container if running) local syntax_valid=1 local syntax_error="" if lxc_running; then # Copy file into container for validation cp "$tmppy" "$LXC_PATH/$LXC_NAME/rootfs/tmp/test_syntax.py" 2>/dev/null syntax_error=$(lxc-attach -n "$LXC_NAME" -- python3 -m py_compile /tmp/test_syntax.py 2>&1) if [ $? -ne 0 ]; then syntax_valid=0 errors="Python syntax error: $syntax_error" fi rm -f "$LXC_PATH/$LXC_NAME/rootfs/tmp/test_syntax.py" else # Container not running - just check for obvious issues # Check for shebang or encoding issues if head -1 "$tmppy" | grep -q '^\xef\xbb\xbf'; then warnings="File has UTF-8 BOM marker" fi fi # Check 3: Look for Streamlit import local has_streamlit=0 if grep -qE '^\s*(import streamlit|from streamlit)' "$tmppy"; then has_streamlit=1 fi if [ "$has_streamlit" = "0" ]; then warnings="${warnings:+$warnings; }No streamlit import found - may not be a Streamlit app" fi # Check 4: Check for obvious security issues (informational) if grep -qE 'subprocess\.(call|run|Popen)|os\.system|eval\(' "$tmppy"; then warnings="${warnings:+$warnings; }Contains shell/eval calls - review code" fi rm -f "$tmppy" json_init_obj json_add_boolean "valid" "$syntax_valid" json_add_string "errors" "$errors" json_add_string "warnings" "$warnings" json_add_int "size" "$file_size" json_add_int "lines" "$line_count" json_add_boolean "has_streamlit_import" "$has_streamlit" json_add_boolean "container_running" "$( lxc_running && echo 1 || echo 0 )" json_close_obj } # Get emancipation status for an app get_emancipation() { read -r input local name name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) if [ -z "$name" ]; then json_error "Missing app name" return fi config_load "$CONFIG" local emancipated emancipated_at domain port emancipated=$(uci -q get "${CONFIG}.${name}.emancipated") emancipated_at=$(uci -q get "${CONFIG}.${name}.emancipated_at") domain=$(uci -q get "${CONFIG}.${name}.emancipated_domain") port=$(uci -q get "${CONFIG}.${name}.port") # Also check instances if [ -z "$port" ]; then for section in $(uci -q show "$CONFIG" | grep "\.app=" | grep "='${name}'" | cut -d. -f2); do port=$(uci -q get "${CONFIG}.${section}.port") [ -n "$port" ] && break done fi json_init_obj json_add_boolean "emancipated" "$( [ "$emancipated" = "1" ] && echo 1 || echo 0 )" json_add_string "emancipated_at" "$emancipated_at" json_add_string "domain" "$domain" json_add_int "port" "${port:-0}" json_close_obj } # Check install progress get_install_progress() { local log_file="/var/log/streamlit-install.log" local status="unknown" local progress=0 local message="" if [ -f "$log_file" ]; then # Check for completion markers if grep -q "Installation complete" "$log_file" 2>/dev/null; then status="completed" progress=100 message="Installation completed successfully" elif grep -q "ERROR" "$log_file" 2>/dev/null; then status="error" message=$(grep "ERROR" "$log_file" | tail -1) else status="running" # Estimate progress based on log content if grep -q "Rootfs created" "$log_file" 2>/dev/null; then progress=80 message="Setting up container..." elif grep -q "Extracting rootfs" "$log_file" 2>/dev/null; then progress=60 message="Extracting container rootfs..." elif grep -q "Downloading Alpine" "$log_file" 2>/dev/null; then progress=40 message="Downloading Alpine rootfs..." elif grep -q "Installing Streamlit" "$log_file" 2>/dev/null; then progress=20 message="Starting installation..." else progress=10 message="Initializing..." fi fi else status="not_started" message="Installation has not been started" fi # Check if process is still running if pgrep -f "streamlitctl install" >/dev/null 2>&1; then status="running" fi json_init_obj json_add_string "status" "$status" json_add_int "progress" "$progress" json_add_string "message" "$message" json_close_obj } # Main RPC handler case "$1" in list) cat <<-EOF { "get_status": {}, "get_config": {}, "save_config": {"http_port": 8501, "http_host": "str", "data_path": "str", "memory_limit": "str", "enabled": "str", "active_app": "str", "headless": "str", "browser_gather_usage_stats": "str", "theme_base": "str", "theme_primary_color": "str"}, "start": {}, "stop": {}, "restart": {}, "install": {}, "uninstall": {}, "update": {}, "get_logs": {"lines": 100}, "list_apps": {}, "get_app": {"name": "str"}, "add_app": {"name": "str", "path": "str"}, "remove_app": {"name": "str"}, "set_active_app": {"name": "str"}, "upload_app": {"name": "str", "content": "str"}, "upload_chunk": {"name": "str", "data": "str", "index": 0}, "upload_finalize": {"name": "str", "is_zip": "str"}, "test_upload": {"name": "str"}, "preview_zip": {"content": "str"}, "upload_zip": {"name": "str", "content": "str", "selected_files": []}, "get_install_progress": {}, "list_instances": {}, "add_instance": {"id": "str", "name": "str", "app": "str", "port": 8501}, "remove_instance": {"id": "str"}, "enable_instance": {"id": "str"}, "disable_instance": {"id": "str"}, "rename_app": {"id": "str", "name": "str"}, "rename_instance": {"id": "str", "name": "str"}, "get_gitea_config": {}, "save_gitea_config": {"enabled": "str", "url": "str", "user": "str", "token": "str"}, "gitea_clone": {"name": "str", "repo": "str"}, "gitea_pull": {"name": "str"}, "gitea_list_repos": {}, "get_source": {"name": "str"}, "save_source": {"name": "str", "content": "str"}, "emancipate": {"name": "str", "domain": "str"}, "get_emancipation": {"name": "str"} } EOF ;; call) case "$2" in get_status) get_status ;; get_config) get_config ;; save_config) save_config ;; start) start_service ;; stop) stop_service ;; restart) restart_service ;; install) install ;; uninstall) uninstall ;; update) update ;; get_logs) get_logs ;; list_apps) list_apps ;; get_app) get_app ;; add_app) add_app ;; remove_app) remove_app ;; set_active_app) set_active_app ;; upload_app) upload_app ;; upload_chunk) upload_chunk ;; upload_finalize) upload_finalize ;; test_upload) test_upload ;; preview_zip) preview_zip ;; upload_zip) upload_zip ;; get_install_progress) get_install_progress ;; list_instances) list_instances ;; add_instance) add_instance ;; remove_instance) remove_instance ;; enable_instance) enable_instance ;; disable_instance) disable_instance ;; rename_app) rename_app ;; rename_instance) rename_instance ;; get_gitea_config) get_gitea_config ;; save_gitea_config) save_gitea_config ;; gitea_clone) gitea_clone ;; gitea_pull) gitea_pull ;; gitea_list_repos) gitea_list_repos ;; get_source) get_source ;; save_source) save_source ;; emancipate) emancipate ;; get_emancipation) get_emancipation ;; *) json_error "Unknown method: $2" ;; esac ;; esac