Skill: techthrasher/clawbox-media-server (version 1.0.1)

--- name: clawbox-media-server description: Bidirectional LAN file sharing for AI agents. Provides a static file server (port 18801) for serving files to users, and an upload server (port 18802) with drag-and-drop web UI for users to send files to the agent. All traffic stays on local network — no internet required. Use when channel lacks inline media support or when you need a simple, LAN-only file exchange layer. --- # Clawbox Media Server & Upload Lightweight HTTP servers for bidirectional file sharing between an AI agent and users on the local network. ## What It Does - **Media Server (port 18801)** — Serves files from a shared directory. Users can browse and download. - **Upload Server (port 18802)** — Accepts file uploads via web UI (drag-and-drop) or API. Saves to the same shared directory. - **Same-origin design** — Upload page served from upload server to avoid CORS/Safari issues. - **Directory listing** — Media server root shows all files with download links. - **Auto-start services** — Systemd units for both servers (optional). All files are stored in `~/projects/shared-media` and instantly accessible to the agent. --- ## Quick Start (One-Command) From your login session (with systemd user bus): ```bash bash ~/.openclaw/workspace/skills/clawbox-media-server/scripts/install-all.sh ``` That's it! The installer: - Creates `~/projects/shared-media` if needed - Starts both servers immediately - Installs systemd service files for auto-start on boot **Access URLs** (replace `192.168.68.75` with your host's LAN IP): - Upload page: `http://192.168.68.75:18802/` - File browser: `http://192.168.68.75:18801/` --- ## Manual Setup If you prefer to start manually: **1. Start Media Server** (serves files): ```bash node ~/.openclaw/workspace/skills/clawbox-media-server/scripts/server.js # Listens on 0.0.0.0:18801 ``` **2. Start Upload Server** (accepts uploads): ```bash UPLOAD_PORT=18802 python3 ~/.openclaw/workspace/skills/clawbox-media-server/scripts/upload-server.py # Serves UI on / and upload endpoint at POST /upload ``` Both read from/write to `~/projects/shared-media` by default. --- ## Usage Patterns ### Agent → User (Serving Files) Agent copies a file to the shared directory: ```bash cp /path/to/output.pdf ~/projects/shared-media/ ``` Then sends the user a link: ``` http://192.168.68.75:18801/output.pdf ``` The user can also browse all files at `http://192.168.68.75:18801/`. --- ### User → Agent (Uploading Files) User opens `http://192.168.68.75:18802/` in any browser, drags files in. ✅ Done. Agent sees the files immediately in `~/projects/shared-media/` and can read them. --- ### Combined Workflow 1. User uploads via `http://192.168.68.75:18802/` 2. Agent reads file from disk (`~/projects/shared-media/<filename>`) 3. Agent processes/transforms it 4. Agent drops result in same directory 5. User downloads from `http://192.168.68.75:18801/<result>` Fully LAN-based, no internet. --- ## Configuration ### Ports & Paths | Variable | Default | Purpose | |----------|---------|---------| | `MEDIA_PORT` | `18801` | Media server port | | `UPLOAD_PORT` | `18802` | Upload server port | | `MEDIA_ROOT` / `UPLOAD_ROOT` | `$HOME/projects/shared-media` | Shared storage directory | Set as environment variables before starting the servers, or edit the systemd service files. ### Systemd Services Service names: - `media-server.service` — Node.js media server - `upload-server.service` — Python upload server Enable auto-start (run once from your login session): ```bash systemctl --user enable media-server.service upload-server.service ``` Start/stop/restart/check status: ```bash systemctl --user start|stop|restart|status media-server.service systemctl --user start|stop|restart|status upload-server.service ``` --- ## API (Upload Server) **Endpoint:** `POST /upload` (multipart/form-data, field name `file`) **Request:** ```bash curl -X POST -F "file=@/path/to/file.jpg" http://192.168.68.75:18802/upload ``` **Response (JSON):** ```json { "filename": "file.jpg", "size": 123456, "download_url": "http://192.168.68.75:18801/file.jpg", "view_url": "http://192.168.68.75:18801/file.jpg", "saved_to": "/home/clawbox/projects/shared-media/file.jpg" } ``` The file is immediately available in the shared directory and via the media server. --- ## Security Notes ### ⚠️ IMPORTANT: Read Before Deploying - **No authentication** — anyone who can reach the ports can upload/download files. Only use on physically secured, trusted networks. Do NOT expose to the internet without additional authentication/reverse proxy. - **Binding address** — By default, servers bind to `0.0.0.0` (all interfaces). To restrict to a specific LAN interface, set `BIND_ADDR` environment variable to your LAN IP (e.g., `192.168.68.75`). Example: ```bash BIND_ADDR=192.168.68.75 MEDIA_PORT=18801 node server.js ``` - **Firewall recommended** — Even on LAN, consider blocking external interfaces (WAN) using a host firewall (`ufw`, `iptables`). Allow only your LAN subnet to ports 18801/18802. - **Directory listing** — The media server root (`:18801/`) shows all filenames. This may leak information. If privacy is needed, disable directory listing by modifying `server.js` or block access via firewall. - **Content Security** — The upload page uses inline JavaScript (CSP relaxed). This is acceptable for a local-only service, but be aware. - **Systemd services** — If you enable systemd services, they run under your user account. Ensure your user account is secure. Consider using a dedicated low-privilege user for production. ### Best Practice: Manual Start for Testing Before enabling systemd auto-start, run the servers manually to verify behavior: ```bash # Terminal 1 cd ~/.openclaw/workspace/skills/clawbox-media-server/scripts BIND_ADDR=192.168.68.75 node server.js # Terminal 2 cd ~/.openclaw/workspace/skills/clawbox-media-server/scripts BIND_ADDR=192.168.68.75 python3 upload-server.py ``` Then test upload/download from another device. Once satisfied, run `install-all.sh` to set up systemd services (with the same `BIND_ADDR` configured in the service files). --- ## Troubleshooting ### "Address already in use" on startup A previous instance is still running. Stop it first: ```bash pkill -f server.js pkill -f upload-server.py ``` Or change ports via environment variables. ### Upload fails with CORS/Safari errors The upload page is served from the same port as the upload endpoint (18802), so cross-origin issues shouldn't occur. Ensure you're accessing `http://<host>:18802/` and not the old media-server copy. ### Systemd user bus not available Run `systemctl --user enable ...` from your normal login shell (not a cron or SSH non-interactive session without DBUS_SESSION_BUS_ADDRESS set). The `install-all.sh` script will start servers manually in any case. --- ## Files & Structure ``` skills/clawbox-media-server/ ├── SKILL.md (this file) ├── server.js (media server) ├── upload-server.py (upload server) ├── upload.html (upload UI, served by upload server) ├── media-server.service (systemd unit) ├── upload-server.service (systemd unit) └── install-all.sh (one-click installer) ``` All files are self-contained; no external npm/pip dependencies beyond Node.js and Python stdlib. --- ## License Open source. Feel free to modify and redistribute.

path: CHANGELOG.md
content: # Changelog

All notable changes to the `clawbox-media-server` skill are documented here.

## [1.0.1] - 2026-03-06

### Fixed
- Corrected systemd service `WorkingDirectory` paths to use `clawbox-media-server` (was `lan-media-server`)
- Made process termination more precise to avoid killing unrelated Node/Python processes
- Added `BIND_ADDR` environment variable to both servers to allow binding to specific LAN interface (security enhancement)

### Improved
- Security documentation expanded with firewall guidance and manual testing recommendations
- Installer now saves PIDs for cleaner restarts
- Systemd service files include `BIND_ADDR` by default

## [1.0.0] - 2026-03-06

### Added
- Initial release
- Media server (port 18801) with directory listing
- Upload server (port 18802) with drag-and-drop web UI
- Same-origin design to avoid CORS/Safari issues
- Systemd service files for auto-start
- One-click installer (`install-all.sh`)
- Bidirectional LAN file sharing workflow
- Configurable bind address (`BIND_ADDR`) for tighter security
- More precise process termination (PID-based, avoids killing unrelated processes)

### Security & Safety
- Added `BIND_ADDR` environment variable to restrict binding to specific LAN interface
- Enhanced documentation with security warnings and best practices
- Updated systemd services to include `BIND_ADDR`
- Improved installer to use exact PID matching for old process cleanup
- Clear guidance on manual testing before enabling systemd auto-start


path: _meta.json
content: ownerId: kn75hb2zhvc8zk8wvz3jwf5kcd80vby2
slug: clawbox-media-server
version: 1.0.1
publishedAt: 1770648434763

path: scripts/install-all.sh
content: #!/bin/bash
# LAN Media Server + Upload Server - One-click installer
# This script sets up both servers and creates systemd services for auto-start.

# set -e  # Disabled to allow continuation if systemd commands fail in this session

echo "🚀 Setting up LAN Media Server & Upload Server..."

# Create shared media directory if missing
mkdir -p "$HOME/projects/shared-media"
echo "📁 Shared media directory: $HOME/projects/shared-media"

# Check dependencies
if ! command -v node &> /dev/null; then
  echo "❌ Node.js is not installed. Please install Node.js first."
  exit 1
fi
echo "✅ Node.js found"

if ! command -v python3 &> /dev/null; then
  echo "❌ Python3 is not installed. Please install Python 3.8+ first."
  exit 1
fi
echo "✅ Python3 found"

# Copy systemd service files
mkdir -p "$HOME/.config/systemd/user"
cp "$(dirname "$0")/../media-server.service" "$HOME/.config/systemd/user/" 2>/dev/null || true
cp "$(dirname "$0")/../upload-server.service" "$HOME/.config/systemd/user/" 2>/dev/null || true
# Also create them if not present (with correct skill name)

# Create systemd services if not exist
if [ ! -f "$HOME/.config/systemd/user/media-server.service" ]; then
  cat > "$HOME/.config/systemd/user/media-server.service" << 'EOF'
[Unit]
Description=LAN Media Server (file hosting)
After=network.target

[Service]
Type=simple
WorkingDirectory=%h/.openclaw/workspace/skills/clawbox-media-server/scripts
Environment=MEDIA_PORT=18801
Environment=BIND_ADDR=0.0.0.0
Environment=MEDIA_ROOT=%h/projects/shared-media
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
EOF
fi

if [ ! -f "$HOME/.config/systemd/user/upload-server.service" ]; then
  cat > "$HOME/.config/systemd/user/upload-server.service" << 'EOF'
[Unit]
Description=LAN Upload Server (file receiving)
After=network.target

[Service]
Type=simple
WorkingDirectory=%h/.openclaw/workspace/skills/clawbox-media-server/scripts
Environment=UPLOAD_PORT=18802
Environment=BIND_ADDR=0.0.0.0
Environment=UPLOAD_ROOT=%h/projects/shared-media
ExecStart=/usr/bin/python3 upload-server.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
EOF
fi

# Reload systemd user daemon (if available)
if command -v systemctl &> /dev/null; then
  systemctl --user daemon-reload 2>/dev/null || echo "⚠️  Could not reload systemd user daemon (no user bus?). Services will be available at next login."
  echo "✅ Systemd service files created"
else
  echo "⚠️  systemctl not found — services not enabled. Start servers manually."
fi

# Start services now
echo "🔄 Starting servers..."

# More targeted kill: use exact pattern with -x to match full command line
MY_SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Use pgrep to find exact PIDs and kill them (more precise than pkill -f)
if pgrep -f "node ${MY_SCRIPT_DIR}/server.js" > /dev/null; then
  pkill -x -f "node ${MY_SCRIPT_DIR}/server.js"
  echo "   Stopped old media server"
fi
if pgrep -f "python3 ${MY_SCRIPT_DIR}/upload-server.py" > /dev/null; then
  pkill -x -f "python3 ${MY_SCRIPT_DIR}/upload-server.py"
  echo "   Stopped old upload server"
fi

# Start media server
MEDIA_BIND_ADDR="${MEDIA_BIND_ADDR:-0.0.0.0}"
echo "🔧 Media server binding to: ${MEDIA_BIND_ADDR}"
MEDIA_PORT="${MEDIA_PORT:-18801}" node "$(dirname "$0")/server.js" > /tmp/media-server.log 2>&1 &
# Save PID for later reference
echo $! > /tmp/media-server.pid
echo "✅ Media server started on port 18801"

# Start upload server
UPLOAD_BIND_ADDR="${UPLOAD_BIND_ADDR:-0.0.0.0}"
echo "🔧 Upload server binding to: ${UPLOAD_BIND_ADDR}"
python3 "$(dirname "$0")/upload-server.py" > /tmp/upload-server.log 2>&1 &
# Save PID for later reference
echo $! > /tmp/upload-server.pid
echo "✅ Upload server started on port 18802"

# Give them a moment
sleep 1

# Check status
if ss -tulpn | grep -q ':18801 '; then
  echo "✅ Media server listening on 18801"
else
  echo "❌ Media server failed to start (check /tmp/media-server.log)"
fi

if ss -tulpn | grep -q ':18802 '; then
  echo "✅ Upload server listening on 18802"
else
  echo "❌ Upload server failed to start (check /tmp/upload-server.log)"
fi

# Get LAN IP
LAN_IP=$(hostname -I | awk '{print $1}')
if [ -z "$LAN_IP" ]; then
  LAN_IP="<your-lan-ip>"
fi

echo ""
echo "🎉 Setup complete!"
echo ""
echo "📋 URLs:"
echo "   Upload page: http://$LAN_IP:18802/"
echo "   File listing: http://$LAN_IP:18801/"
echo ""
echo "🔧 To enable auto-start on boot, run:"
echo "   systemctl --user enable media-server.service upload-server.service"
echo ""
echo "📁 Files are stored in: $HOME/projects/shared-media"
echo ""
echo "You can now drag-and-drop files to http://$LAN_IP:18802/ from any device on your LAN."


path: scripts/server.js
content: #!/usr/bin/env node
// LAN Media Server — lightweight static file server for AI agent media sharing
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = parseInt(process.env.MEDIA_PORT || '18801', 10);
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
const MEDIA_ROOT = path.resolve(process.env.MEDIA_ROOT || path.join(process.env.HOME, 'projects/shared-media'));

const MIME = {
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
  '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
  '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.pdf': 'application/pdf',
  '.html': 'text/html', '.json': 'application/json', '.txt': 'text/plain',
  '.css': 'text/css', '.js': 'application/javascript', '.zip': 'application/zip',
};

function escapeHtml(str) {
  return String(str).replace(/[&<>"']/g, ch => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  }[ch]));
}

const server = http.createServer((req, res) => {
  if (req.method !== 'GET') {
    res.writeHead(405); res.end('Method Not Allowed'); return;
  }

  let urlPath;
  try {
    urlPath = decodeURIComponent(req.url.split('?')[0]);
  } catch {
    res.writeHead(400); res.end('Bad request'); return;
  }

  // Block null bytes and double-encoded traversal
  if (urlPath.includes('\0') || urlPath.includes('..')) {
    res.writeHead(403); res.end('Forbidden'); return;
  }

  const safePath = path.normalize(urlPath);
  const filePath = path.join(MEDIA_ROOT, safePath);

  // Block path traversal
  if (!filePath.startsWith(MEDIA_ROOT + path.sep) && filePath !== MEDIA_ROOT) {
    res.writeHead(403); res.end('Forbidden'); return;
  }

  // If root path, return an HTML directory listing
  if (filePath === MEDIA_ROOT || filePath === MEDIA_ROOT + '/') {
    fs.readdir(MEDIA_ROOT, (err, files) => {
      if (err) {
        res.writeHead(404);
        res.end('Directory not found');
        return;
      }
      const items = [];
      files.forEach(file => {
        const full = path.join(MEDIA_ROOT, file);
        try {
          const stats = fs.statSync(full);
          if (stats.isFile()) {
            items.push({
              name: file,
              size: stats.size,
              mtime: stats.mtime
            });
          }
        } catch (e) {}
      });
      items.sort((a, b) => b.mtime - a.mtime);
      const hostHeader = req.headers.host || 'localhost:18801';
      const hostname = hostHeader.split(':')[0] || 'localhost';
      let html = `<!DOCTYPE html><html><head><title>LAN Media Server</title><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:900px;margin:40px auto;padding:20px;background:#f5f5f5;color:#333}h1{margin-bottom:10px}.list{background:white;border-radius:8px;padding:15px}li{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee}li:last-child{border-bottom:none}a{color:#0066cc;text-decoration:none}small{color:#666}.note{margin-top:20px;padding:12px;background:#fff3cd;border-radius:6px;font-size:14px;color:#856404}</style></head><body><h1>📁 Shared Files</h1><p>Upload new files via <a href="http://${hostname}:18802/">http://${hostname}:18802/</a></p><ul class="list">`;
      items.forEach(item => {
        const size = item.size > 1024 * 1024 ? (item.size / (1024 * 1024)).toFixed(1) + ' MB' : item.size > 1024 ? (item.size / 1024).toFixed(1) + ' KB' : item.size + ' B';
        const date = item.mtime.toLocaleString();
        html += `<li><a href="/${encodeURIComponent(item.name)}">${escapeHtml(item.name)}</a><small>${size} • ${date}</small></li>`;
      });
      html += `</ul><div class="note">Files are served on this port (18801). Upload new files via port 18802.</div></body></html>`;
      res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
      res.end(html);
      return;
    });
    return;
  }

  fs.stat(filePath, (err, stats) => {
    if (err || !stats.isFile()) {
      res.writeHead(404); res.end('Not found'); return;
    }
    const ext = path.extname(filePath).toLowerCase();
    res.writeHead(200, {
      'Content-Type': MIME[ext] || 'application/octet-stream',
      'Content-Length': stats.size,
      'Cache-Control': 'public, max-age=3600',
      'X-Content-Type-Options': 'nosniff',
      'Content-Security-Policy': "default-src 'self'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'unsafe-eval'",
    });
    fs.createReadStream(filePath).pipe(res);
  });
});

server.listen(PORT, BIND_ADDR, () => {
  console.log(`LAN Media Server running on http://${BIND_ADDR}:${PORT}`);
  console.log(`Serving: ${MEDIA_ROOT}`);
});


path: scripts/setup.sh
content: #!/usr/bin/env bash
# Setup LAN Media Server as a systemd user service
set -euo pipefail

PORT="${MEDIA_PORT:-18801}"
MEDIA_ROOT="${MEDIA_ROOT:-$HOME/projects/shared-media}"
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SERVER_SCRIPT="$SKILL_DIR/scripts/server.js"
SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_FILE="$SERVICE_DIR/media-server.service"

# Detect node
NODE_BIN="$(command -v node 2>/dev/null || echo '/usr/bin/node')"
if [[ ! -x "$NODE_BIN" ]]; then
  echo "❌ Node.js not found. Install it first." >&2
  exit 1
fi

# Create shared media directory
mkdir -p "$MEDIA_ROOT"
echo "📁 Media directory: $MEDIA_ROOT"

# Create systemd user service
mkdir -p "$SERVICE_DIR"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=LAN Media Server (port $PORT)
After=network.target

[Service]
ExecStart=$NODE_BIN $SERVER_SCRIPT
Restart=always
RestartSec=3
Environment=NODE_ENV=production
Environment="MEDIA_PORT=$PORT"
Environment="MEDIA_ROOT=$MEDIA_ROOT"

[Install]
WantedBy=default.target
EOF

echo "📝 Created service: $SERVICE_FILE"

# Enable and start
systemctl --user daemon-reload
systemctl --user enable media-server.service
systemctl --user restart media-server.service

echo ""
echo "✅ LAN Media Server running!"
echo "   Port: $PORT"
echo "   Directory: $MEDIA_ROOT"

# Detect LAN IP
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo "   URL: http://$LAN_IP:$PORT/"
echo ""
echo "💡 Drop files into $MEDIA_ROOT and share links like:"
echo "   http://$LAN_IP:$PORT/my-screenshot.jpg"

# Check lingering (needed for services to survive logout)
if ! loginctl show-user "$(whoami)" 2>/dev/null | grep -q 'Linger=yes'; then
  echo ""
  echo "⚠️  User lingering not enabled. Run this to survive reboots:"
  echo "   sudo loginctl enable-linger $(whoami)"
fi


path: scripts/upload-server.py
content: #!/usr/bin/env python3
"""
LAN Upload Server — Accept file uploads over LAN and store in shared media directory.
Companion to the LAN Media Server. Saves files to the same MEDIA_ROOT.
"""
import http.server
import socketserver
import os
import cgi
import json
from pathlib import Path

PORT = int(os.getenv('UPLOAD_PORT', '18802'))
MEDIA_ROOT = Path(os.getenv('UPLOAD_ROOT', os.path.expanduser('~/projects/shared-media')))
HOST = os.getenv('BIND_ADDR', '0.0.0.0')  # Bind address (default: all interfaces). Set to specific LAN IP for tighter security.

os.makedirs(MEDIA_ROOT, exist_ok=True)

class UploadHandler(http.server.BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        # CORS preflight
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()

    def do_GET(self):
        # Serve the upload page (same origin)
        if self.path == '/' or self.path == '/upload.html':
            try:
                # Look for upload.html in the same directory as this script
                script_dir = os.path.dirname(os.path.abspath(__file__))
                html_path = os.path.join(script_dir, 'upload.html')
                with open(html_path, 'rb') as f:
                    content = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
                return
            except Exception as e:
                self.send_response(500)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(f'Error loading page: {e}'.encode())
                return
        # Simple status for /upload (GET)
        if self.path == '/upload':
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(b'LAN Upload Server is running. Use POST /upload to send a file.')
            return
        self.send_response(404)
        self.end_headers()
        self.wfile.write(b'Not found.')

    def do_POST(self):
        if self.path != '/upload':
            self.send_response(404)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            return

        # Parse multipart form data
        try:
            form = cgi.FieldStorage(
                fp=self.rfile,
                headers=self.headers,
                environ={'REQUEST_METHOD': 'POST', 'CONTENT_TYPE': self.headers.get('Content-Type')}
            )
        except Exception as e:
            self.send_response(400)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({'error': 'Invalid form data'}).encode())
            return

        if 'file' not in form:
            self.send_response(400)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({'error': 'No file field provided'}).encode())
            return

        file_item = form['file']
        if not file_item.filename:
            self.send_response(400)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({'error': 'No file selected'}).encode())
            return

        # Secure filename
        filename = os.path.basename(file_item.filename)
        dest_path = MEDIA_ROOT / filename

        try:
            with open(dest_path, 'wb') as f:
                # Read in chunks to handle large files
                while True:
                    chunk = file_item.file.read(8192)
                    if not chunk:
                        break
                    f.write(chunk)
        except Exception as e:
            self.send_response(500)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({'error': f'Failed to save: {str(e)}'}).encode())
            return

        # Success response
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Content-Type', 'application/json')
        self.end_headers()
        # Return both direct download and view URLs (view uses media server)
        server_addr = self.server.server_address
        host = self.headers.get('Host', f'{server_addr[0]}:{PORT}')
        # Media server runs on port 18801
        download_url = f'http://{host.split(":")[0]}:18801/{filename}'
        view_url = download_url  # same endpoint; media server decides content-type
        self.wfile.write(json.dumps({
            'filename': filename,
            'size': dest_path.stat().st_size,
            'download_url': download_url,
            'view_url': view_url,
            'saved_to': str(dest_path)
        }).encode())

    def log_message(self, format, *args):
        # Log to stderr ( Quiet by default, but will show errors)
        return

if __name__ == '__main__':
    # Allow immediate reuse of the address after the server stops (avoid "Address already in use")
    socketserver.TCPServer.allow_reuse_address = True
    with socketserver.TCPServer((HOST, PORT), UploadHandler) as httpd:
        print(f"LAN Upload Server running on http://{HOST}:{PORT}/upload")
        print(f"Files will be saved to: {MEDIA_ROOT}")
        print("Press Ctrl+C to stop.")
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\nShutting down.")
            httpd.server_close()


path: scripts/upload.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>LAN File Upload</title>
  <style>
    * { box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      max-width: 600px;
      margin: 40px auto;
      padding: 20px;
      background: #f5f5f5;
      color: #333;
    }
    h1 { margin-bottom: 10px; font-size: 24px; }
    p.subtitle { color: #666; margin-bottom: 30px; }
    .dropzone {
      border: 3px dashed #007bff;
      border-radius: 12px;
      padding: 60px 20px;
      text-align: center;
      background: white;
      cursor: pointer;
      transition: all 0.2s;
      margin-bottom: 20px;
    }
    .dropzone:hover, .dropzone.dragover {
      border-color: #0056b3;
      background: #f0f8ff;
    }
    .dropzone p { margin: 0; color: #666; font-size: 16px; }
    .dropzone input[type="file"] {
      position: absolute;
      width: 0;
      height: 0;
      opacity: 0;
    }
    .files-list {
      background: white;
      border-radius: 8px;
      padding: 15px;
      margin-top: 20px;
    }
    .file-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 10px;
      border-bottom: 1px solid #eee;
    }
    .file-item:last-child { border-bottom: none; }
    .file-name { font-weight: 500; word-break: break-all; }
    .file-status { font-size: 14px; }
    .status-success { color: #28a745; }
    .status-error { color: #dc3545; }
    .status-uploading { color: #007bff; }
    .progress-bar {
      height: 4px;
      background: #e9ecef;
      border-radius: 2px;
      margin-top: 8px;
      overflow: hidden;
    }
    .progress-fill {
      height: 100%;
      background: #007bff;
      width: 0%;
      transition: width 0.2s;
    }
    .clear-btn {
      margin-top: 20px;
      padding: 10px 20px;
      background: #6c757d;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
    }
    .clear-btn:hover { background: #5a6268; }
    .note {
      margin-top: 30px;
      padding: 15px;
      background: #fff3cd;
      border-radius: 8px;
      font-size: 14px;
      color: #856404;
    }
  </style>
</head>
<body>
  <h1>📤 LAN File Upload</h1>
  <p class="subtitle">Send files to the agent on your local network</p>

  <div class="dropzone" id="dropzone">
    <p>📁 Drop files here or click to select</p>
    <input type="file" id="fileInput" multiple>
  </div>

  <div class="files-list" id="filesList" style="display: none;"></div>

  <button class="clear-btn" onclick="clearFiles()" style="display: none;" id="clearBtn">Clear Completed</button>

  <div class="note">
    <strong>Note:</strong> Uploaded files become accessible to anyone on your LAN via the media server. Use only on trusted networks.
  </div>

  <script>
    // For upload, use same origin (relative URL) to avoid cross-origin issues
    const UPLOAD_URL = 'upload';
    // Media server runs on different port (18801), so we need the hostname to construct URLs
    const hostname = window.location.hostname || 'localhost';
    const MEDIA_URL_BASE = `http://${hostname}:18801/`;

    const dropzone = document.getElementById('dropzone');
    const fileInput = document.getElementById('fileInput');
    const filesList = document.getElementById('filesList');
    const clearBtn = document.getElementById('clearBtn');

    let files = [];

    dropzone.addEventListener('click', () => fileInput.click());
    fileInput.addEventListener('change', (e) => handleFiles(e.target.files));

    dropzone.addEventListener('dragover', (e) => {
      e.preventDefault();
      dropzone.classList.add('dragover');
    });
    dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
    dropzone.addEventListener('drop', (e) => {
      e.preventDefault();
      dropzone.classList.remove('dragover');
      handleFiles(e.dataTransfer.files);
    });

    function handleFiles(fileList) {
      for (let file of fileList) {
        files.push({ file, status: 'pending', progress: 0 });
      }
      renderFiles();
      uploadAll();
    }

    function renderFiles() {
      if (files.length === 0) {
        filesList.style.display = 'none';
        clearBtn.style.display = 'none';
        return;
      }
      filesList.style.display = 'block';
      clearBtn.style.display = 'block';

      const completedCount = files.filter(f => f.status === 'done' || f.status === 'error').length;
      if (completedCount === files.length) {
        clearBtn.textContent = 'Clear Completed';
      } else {
        clearBtn.textContent = `Clear Completed (${completedCount}/${files.length})`;
      }

      filesList.innerHTML = files.map((f, i) => {
        let linkHtml = '';
        if (f.status === 'done' && f.url) {
          linkHtml = ` <a href="${escapeHtml(f.url)}" target="_blank" style="font-size:0.9em;color:#0066cc;text-decoration:none">⬇️ Download</a>`;
        }
        return `
          <div class="file-item" data-index="${i}">
            <div>
              <div class="file-name">${escapeHtml(f.file.name)}</div>
              <div class="progress-bar" style="visibility: ${f.status === 'uploading' ? 'visible' : 'hidden'}">
                <div class="progress-fill" style="width: ${f.progress}%"></div>
              </div>
            </div>
            <div class="file-status status-${f.status}">
              ${getStatusText(f)}${linkHtml}
            </div>
          </div>
        `;
      }).join('');
    }

    function getStatusText(f) {
      switch (f.status) {
        case 'pending': return '⏳ Queued';
        case 'uploading': return `📤 ${f.progress}%`;
        case 'done': return '✅ Done';
        case 'error': return `❌ ${f.error || 'Failed'}`;
        default: return '';
      }
    }

    async function uploadAll() {
      for (let i = 0; i < files.length; i++) {
        if (files[i].status !== 'pending') continue;
        await uploadFile(i);
      }
      renderFiles();
    }

    async function uploadFile(index) {
      const f = files[index];
      f.status = 'uploading';
      f.progress = 0;
      renderFiles();

      const formData = new FormData();
      formData.append('file', f.file);

      try {
        const xhr = new XMLHttpRequest();
        xhr.open('POST', UPLOAD_URL, true);

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            f.progress = Math.round((e.loaded / e.total) * 100);
            renderFiles();
          }
        });

        const response = await new Promise((resolve, reject) => {
          xhr.onload = () => {
            if (xhr.status >= 200 && xhr.status < 300) {
              resolve(JSON.parse(xhr.responseText));
            } else {
              reject(new Error(xhr.statusText || 'Upload failed'));
            }
          };
          xhr.onerror = () => reject(new Error('Network error'));
          xhr.send(formData);
        });

        f.status = 'done';
        f.response = response;
        f.url = response.view_url || response.download_url;
      } catch (err) {
        f.status = 'error';
        f.error = err.message;
      }
      renderFiles();
    }

    function clearFiles() {
      files = files.filter(f => f.status !== 'done' && f.status !== 'error');
      renderFiles();
    }

    function escapeHtml(text) {
      const div = document.createElement('div');
      div.textContent = text;
      return div.innerHTML;
    }
  </script>
</body>
</html>


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)