diff --git a/app.py b/app.py index 562b119..bd7af4e 100644 --- a/app.py +++ b/app.py @@ -38,6 +38,7 @@ CONFIG_FILE = BASE_DIR / 'config.json' STATE_FILE = BASE_DIR / 'state.json' LOG_DIR = BASE_DIR / 'logs' LOG_FILE = LOG_DIR / 'picopy.log' +INTERNAL_DEST_DIR = BASE_DIR / 'internal' LOG_DIR.mkdir(parents=True, exist_ok=True) logging.basicConfig( @@ -56,6 +57,8 @@ DEFAULT_CONFIG = { 'source_ports': [], # [{port, label}, ...] 'source_port': None, 'source_label': '', # Migration legacy 'dest_port': None, 'dest_label': '', + 'dest_type': 'usb', 'internal_dest_label': 'Interner Speicher', + 'internal_share_enabled': False, 'folder_format': '%Y-%m-%d', 'add_time': True, 'subfolder': True, 'auto_copy': True, 'file_filter': '', 'exclude_system': True, @@ -408,6 +411,163 @@ def wg_save_config(content: str): return False, str(e) +# -- Interner Speicher / SMB-Freigabe ----------------------------------------- + +SAMBA_CONF = Path('/etc/samba/smb.conf') +SAMBA_BEGIN = '# BEGIN PICOPY INTERNAL SHARE' +SAMBA_END = '# END PICOPY INTERNAL SHARE' + +internal_share_state = { + 'installed': False, + 'enabled': False, + 'active': False, + 'path': str(INTERNAL_DEST_DIR), + 'share': 'PiCopy', + 'pkg_running': False, + 'pkg_error': None, + 'error': None, +} +internal_share_lock = threading.Lock() + + +def _internal_usage(): + INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True) + usage = shutil.disk_usage(INTERNAL_DEST_DIR) + return { + 'path': str(INTERNAL_DEST_DIR), + 'total': usage.total, + 'used': usage.used, + 'free': usage.free, + } + + +def internal_dest_device(cfg=None): + cfg = cfg or load_cfg() + usage = _internal_usage() + return { + 'device': 'internal', + 'usb_port': '__internal__', + 'mount': str(INTERNAL_DEST_DIR), + 'label': cfg.get('internal_dest_label') or 'Interner Speicher', + 'size': _fmt_bytes(usage['free']) + ' frei', + 'internal': True, + } + + +def smbd_installed(): + return shutil.which('smbd') is not None + + +def _systemctl(*args, timeout=20): + try: + return subprocess.run(['systemctl'] + list(args), capture_output=True, + text=True, timeout=timeout) + except Exception as e: + return subprocess.CompletedProcess(['systemctl'] + list(args), 1, + stdout='', stderr=str(e)) + + +def _smbd_active(): + if not smbd_installed(): + return False + r = _systemctl('is-active', 'smbd', timeout=5) + return r.returncode == 0 and r.stdout.strip() == 'active' + + +def internal_share_update_state(): + cfg = load_cfg() + usage = _internal_usage() + with internal_share_lock: + internal_share_state.update( + installed=smbd_installed(), + enabled=bool(cfg.get('internal_share_enabled')), + active=_smbd_active(), + path=usage['path'], + total=usage['total'], + used=usage['used'], + free=usage['free'], + ) + return dict(internal_share_state) + + +def _write_samba_share(enabled: bool): + old = SAMBA_CONF.read_text(encoding='utf-8') if SAMBA_CONF.exists() else '' + pattern = re.compile(rf'\n?{re.escape(SAMBA_BEGIN)}.*?{re.escape(SAMBA_END)}\n?', re.S) + cleaned = pattern.sub('\n', old).rstrip() + '\n' + if enabled: + INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True) + INTERNAL_DEST_DIR.chmod(0o755) + block = f""" +{SAMBA_BEGIN} +[PiCopy] + path = {INTERNAL_DEST_DIR} + browseable = yes + read only = yes + guest ok = yes + force user = root +{SAMBA_END} +""" + cleaned += block + tmp = SAMBA_CONF.with_suffix('.conf.picopy_tmp') + tmp.write_text(cleaned, encoding='utf-8') + os.replace(str(tmp), str(SAMBA_CONF)) + + +def _install_samba_if_needed(): + if smbd_installed(): + return True, '' + with internal_share_lock: + internal_share_state.update(pkg_running=True, pkg_error=None) + try: + r = subprocess.run(['apt-get', 'install', '-y', 'samba'], + capture_output=True, text=True, timeout=300, + env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'}) + if r.returncode != 0: + err = (r.stderr.strip().splitlines()[-1] + if r.stderr.strip() else 'samba-Installation fehlgeschlagen') + with internal_share_lock: + internal_share_state['pkg_error'] = err + return False, err + return True, '' + except Exception as e: + with internal_share_lock: + internal_share_state['pkg_error'] = str(e) + return False, str(e) + finally: + with internal_share_lock: + internal_share_state['pkg_running'] = False + + +def set_internal_share_enabled(enabled: bool): + ok, err = (True, '') + if enabled: + ok, err = _install_samba_if_needed() + if not ok: + return False, err + elif not smbd_installed(): + cfg = load_cfg() + cfg['internal_share_enabled'] = False + save_cfg(cfg) + internal_share_update_state() + return True, '' + try: + _write_samba_share(enabled) + if enabled: + _systemctl('enable', '--now', 'smbd', timeout=60) + _systemctl('restart', 'smbd', timeout=60) + else: + _systemctl('restart', 'smbd', timeout=60) + cfg = load_cfg() + cfg['internal_share_enabled'] = enabled + save_cfg(cfg) + internal_share_update_state() + return True, '' + except Exception as e: + with internal_share_lock: + internal_share_state['error'] = str(e) + return False, str(e) + + def wg_monitor(): while True: try: @@ -485,6 +645,9 @@ def usb_devices(): return result def ensure_mount(dev_info): + if dev_info.get('internal'): + INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True) + return str(INTERNAL_DEST_DIR), False mp = dev_info.get('mount') if mp: return mp, False @@ -586,6 +749,12 @@ def _resolve_source_ports(cfg) -> list: return ports +def _configured_destination(cfg, devs): + if cfg.get('dest_type') == 'internal': + return internal_dest_device(cfg) + return next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None) + + def do_copy(src_devs, dst_dev, cfg): """Kopiert von einer oder mehreren Quellen auf ein Ziel.""" dst_mp = None @@ -849,7 +1018,9 @@ def do_copy(src_devs, dst_dev, cfg): def check_auto_copy(): cfg = load_cfg() src_ports = _resolve_source_ports(cfg) - if not cfg.get('auto_copy') or not src_ports or not cfg.get('dest_port'): + if not cfg.get('auto_copy') or not src_ports: + return + if cfg.get('dest_type') != 'internal' and not cfg.get('dest_port'): return with copy_lock: if copy_state['running'] or copy_state['error']: @@ -857,7 +1028,7 @@ def check_auto_copy(): devs = usb_devices() srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports] srcs = [s for s in srcs if s is not None] - dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None) + dst = _configured_destination(cfg, devs) if srcs and dst: log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden') threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True).start() @@ -1272,7 +1443,8 @@ def r_status(): ws = dict(wifi_state) with wg_lock: wgs = dict(wg_state) - return jsonify(copy=cs, wifi=ws, vpn=wgs) + share = internal_share_update_state() + return jsonify(copy=cs, wifi=ws, vpn=wgs, internal_share=share) @app.route('/api/copy/start', methods=['POST']) def r_start(): @@ -1292,7 +1464,7 @@ def r_start(): if wanted_ports is not None: srcs = [s for s in srcs if s['usb_port'] in wanted_ports] if not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400 - dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None) + dst = _configured_destination(cfg, devs) if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400 _copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True) _copy_thread.start() @@ -1304,6 +1476,21 @@ def r_cancel(): copy_state['running'] = False return jsonify(ok=True) + +@app.route('/api/internal-share/status') +def r_internal_share_status(): + return jsonify(internal_share_update_state()) + + +@app.route('/api/internal-share', methods=['POST']) +def r_internal_share_set(): + data = request.get_json(force=True) or {} + enabled = bool(data.get('enabled')) + ok, err = set_internal_share_enabled(enabled) + if not ok: + return jsonify(error=err), 500 + return jsonify(ok=True, status=internal_share_update_state()) + @app.route('/api/wifi/scan') def r_wifi_scan(): nets = scan_wifi_networks() @@ -1561,6 +1748,9 @@ def _drop_browse_mount(port): def get_browse_mp(dev): + if dev.get('internal'): + INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True) + return str(INTERNAL_DEST_DIR) port = dev.get('usb_port', '') # Auto-mount vom System bevorzugen @@ -1590,7 +1780,9 @@ def r_browse(): rpath = request.args.get('path', '').lstrip('/') devs = usb_devices() - dev = next((d for d in devs if d['usb_port'] == port), None) + dev = internal_dest_device(load_cfg()) if port == '__internal__' else None + if dev is None: + dev = next((d for d in devs if d['usb_port'] == port), None) if not dev: return jsonify(error='Gerät nicht verbunden - bitte neu einstecken'), 404 @@ -2065,9 +2257,16 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys