diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc929fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +dist/ +build/ +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md index b50a2a0..f4ce74d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,297 @@ # ftptui -FTP/SFTP TUI-Client in Python mit SSH-FTP-Unterstützung \ No newline at end of file +> Interaktiver **FTP- und SFTP-Client** als Terminal-User-Interface in Python. + +`ftptui` ist ein schlanker, aber vollwertiger Dateibrowser für die Kommandozeile. +Er unterstützt klassisches **FTP** (RFC 959, über die Python-Standardbibliothek +`ftplib`) sowie **SFTP** (SSH File Transfer Protocol, über `paramiko`). Damit deckt +das Tool den überwiegenden Teil der realen Dateiübertragungs-Szenarien ab – +einschließlich SSH-basiertem FTP. + +Das Interface ist zweispaltig aufgebaut: + +- **links** das lokale Dateisystem, +- **rechts** das entfernte System, + +mit Dateiübertragung in beide Richtungen, rekursivem Hoch-/Herunterladen von +Verzeichnissen, Anlegen, Umbenennen und Löschen sowie speicherbaren +Verbindungsprofilen. + +--- + +## Inhaltsverzeichnis + +- [Funktionen](#funktionen) +- [Unterstützte Protokolle](#unterstützte-protokolle) +- [Installation](#installation) +- [Verwendung](#verwendung) +- [Tastaturkürzel](#tastaturkürzel) +- [Projektstruktur](#projektstruktur) +- [Architektur](#architektur) +- [Konfiguration & Profile](#konfiguration--profile) +- [Entwicklung](#entwicklung) +- [Testen](#testen) +- [Lizenz](#lizenz) + +--- + +## Funktionen + +- **Dual-Pane-Dateibrowser** – lokales und entferntes Verzeichnis nebeneinander, + Spalten: Name, Größe, Änderungsdatum. +- **FTP** über die Standardbibliothek, **SFTP** über `paramiko` (SSH-FTP). +- **Herunterladen** (`l`) und **Hochladen** (`u`) einzelner Dateien. +- **Rekursive Übertragung** kompletter Verzeichnisbäume. +- **Verzeichnisse anlegen** (`n`), **umbenennen** (`r`), **löschen** (`d`), + rekursives Löschen nicht-leerer Ordner. +- **Navigation** mit Maus/Tastatur, „eine Ebene hoch“ (`←`), Doppelklick/`Enter`. +- **Verbindungsprofile** werden als JSON unter `~/.config/ftptui/profiles.json` + gespeichert und können wieder geladen werden. +- Passwortfelder sind abgedunkelt (`password=True`). +- Modernes, terminalfreundliches Layout auf Basis von + [Textual](https://textual.textualize.io/). + +--- + +## Unterstützte Protokolle + +| Protokoll | Beschreibung | Bibliothek / Quelle | +|-----------|-------------------------------------------|------------------------------------| +| `ftp` | Klassisches File Transfer Protocol (RFC 959) | Standard (kein Drittpaket nötig) | +| `sftp` | SSH File Transfer Protocol (SSH-FTP) | `paramiko` | + +> **Hinweis:** „SSH-FTP“ kann zweierlei bedeuten – das SFTP-Subsystem über eine +> SSH-Schicht (hier umgesetzt) oder FTP über einen SSH-Tunnel (sog. „FTP over +> SSH“). In `ftptui` wird **SFTP über SSH** unterstützt, der heute übliche und +> sichere Weg. + +--- + +## Installation + +### Voraussetzungen + +- Python **≥ 3.10** +- `pip` und idealerweise eine virtuelle Umgebung + +### Aus dem Quellverzeichnis + +```bash +# virtuelle Umgebung anlegen und aktivieren +python3 -m venv .venv +source .venv/bin/activate + +# Abhängigkeiten installieren +pip install -e . + +# Abhängigkeiten (nur Laufzeit): +# textual - TUI-Framework +# paramiko - SFTP/SSH +``` + +### Als Paket + +```bash +pip install . +``` + +### Als installiertes Kommando + +Nach `pip install -e .` steht das Kommando `ftptui` global in der Umgebung bereit: + +```bash +ftptui +``` + +--- + +## Verwendung + +1. Starten: `ftptui` + +2. Im **Verbindungsbildschirm** die Daten eingeben: + - **Verbindungsprofil** – ein zuvor gespeichertes Profil auswählen, oder ein + neues anlegen. + - **Protokoll** – `sftp` (SSH) oder `ftp`. + - **Host**, **Port** (SFTP-Vorgabe `22`, FTP-Vorgabe `21`), **Benutzer**, + **Passwort**. + - Auf **Verbinden** klicken. Der Port wird bei Protokollwechsel automatisch + auf den jeweiligen Standard gesetzt. + +3. Nach erfolgreicher Verbindung erscheint der **Browser-Bildschirm** mit zwei + Panelen: + - links `/` des lokalen Rechners bzw. das Home-Verzeichnis, + - rechts das Home-/Root-Verzeichnis des entfernten Systems. + +4. Zwischen den Paneelen wechseln mit `Tab`. Die fokussierte Tabelle erhält die + Aktionen (Hoch-/Herunterladen, Umbenennen, Löschen …). + +> **Tipp:** Die Aktionen `u`/`l` wirken auf das gerade **in der fokussierten +> Tabelle** angewählte Element und übertragen in die jeweils andere Seite. + +--- + +## Tastaturkürzel + +| Taste | Aktion | +|------------|---------------------------------| +| `Tab` | Zwischen lokalem/entferntem Paneel wechseln | +| `Enter` | Ordner öffnen / in Datei wechseln | +| `←` | Eine Verzeichnisebene höher | +| `↑ ..` | Eine Ebene hoch (Tabellenzeile) | +| `l` | Auswahl **herunterladen** (entfernt → lokal) | +| `u` | Auswahl **hochladen** (lokal → entfernt) | +| `n` | Neues Verzeichnis anlegen | +| `d` | Auswahl löschen (rekursiv) | +| `r` | Auswahl umbenennen | +| `q` / `Esc`| Beenden | + +--- + +## Projektstruktur + +``` +ftptui/ +├── pyproject.toml # Paketmetadaten, Abhängigkeiten, Einstiegspunkt +├── README.md # diese Dokumentation +├── .gitignore +└── ftptui/ + ├── __init__.py # Versionsnummer + ├── app.py # Textual-TUI: Verbindungs- & Browser-Bildschirme + ├── backend.py # Fabrik: wählt FTP- oder SFTP-Transport + ├── config.py # Speicherung/Laden der Verbindungsprofile (JSON) + ├── models.py # Datentypen & Transport-Protokoll + ├── ftp_backend.py # FTP-Adapter (ftplib) + └── sftp_backend.py # SFTP-Adapter (paramiko) +``` + +--- + +## Architektur + +Die App trennt **Oberfläche** (Textual) von **Transport** (Netzwerkprotokolle) +durch ein kleines Protokoll/Interface: + +- `FileTransferBackend` (in `models.py`) definiert die gemeinsame API. +- `FTPBackend` und `SFTPBackend` implementieren sie. +- `create_backend(protocol)` in `backend.py` liefert den passenden Adapter. + +Gemeinsame Schnittstelle (Auszug): + +```python +def connect(host, port, username, password) -> None +def listdir(path) -> list[RemoteEntry] +def chdir(path) / pwd() -> str +def download(remote, local) -> None +def upload(local, remote) -> None +def mkdir(path) / remove / rmdir / rename +def close() -> None +``` + +`RemoteEntry` beschreibt einen Eintrag mit `name`, `kind` (`file`/`dir`), +`size` und `modified`. Dadurch kann die Oberfläche unabhängig vom Protokoll +arbeiten – das Hinzufügen weiterer Backends (z. B. WebDAV) ist ohne Änderung +der UI möglich. + +### Pfadbehandlung + +Beide Backends normalisieren Pfade zu **absoluten** Angaben relativ zum Root +des entfernten Systems. FTP-Operationen setzen das Arbeitsverzeichnis vor einer +Aktion auf `/`, sodass Dateiübertragungen unabhängig vom zuletzt betrachteten +Ordner zuverlässig funktionieren. SFTP nutzt absolute Pfade direkt. + +### Profilwertung + +Profile werden als JSON in `~/.config/ftptui/profiles.json` (bzw. +`$XDG_CONFIG_HOME/ftptui/profiles.json`) gespeichert. Das Passwort wird nur +abgelegt, wenn es entsprechend markiert wurde (aktuell standardmäßig direkt +gespeichert – Datenschutz-Hinweis siehe unten). + +--- + +## Konfiguration & Profile + +Profile speichern: im Verbindungsbildschirm Daten eingeben und **Profil speichern** +drücken. Gespeicherte Einträge erscheinen künftig im Dropdown **Verbindungsprofil**. + +Beispieldatei `~/.config/ftptui/profiles.json`: + +```json +[ + { + "name": "git.sysdaemon.xyz", + "protocol": "sftp", + "host": "git.sysdaemon.xyz", + "port": 22, + "username": "user", + "password": "", + "save_password": false + }, + { + "name": "backup-server", + "protocol": "ftp", + "host": "backup.local", + "port": 21, + "username": "ftpuser", + "password": "secret", + "save_password": true + } +] +``` + +> **Sicherheitshinweis:** Speichern Sie Passwörter in Profilen nur auf +> vertrauenswürdigen Systemen. Die Datei liegt im Klartext im Benutzerverzeichnis. + +--- + +## Entwicklung + +```bash +# Umgebung +python3 -m venv .venv && source .venv/bin/activate +pip install -e . "textual>=0.80.0" "paramiko>=3.4.0" + +# Laufzeit prüfen +python -c "import ftptui; print(ftptui.__version__)" + +# App booten (Konsolen-Test) +ftptui +``` + +### Code-Stil & Qualität + +- Typannotationen (`from __future__ import annotations`) durchgängig. +- Protokoll-/Interface-basierte Implementierung mit `typing.Protocol`. +- Keine Abhängigkeit auf Drittanbieter außer `textual` und `paramiko`. + +--- + +## Testen + +Die Backends werden gegen **lokale Testserver** end-to-end getestet: + +- **FTP** gegen `pyftpdlib` (nur für Tests; nicht Teil der Laufzeit-Abhängigkeiten), +- **SFTP** gegen eine eigene `paramiko`-SSH-Server-Implementierung. + +Ablauf der Tests: Verbinden, Listings, Download, Upload, `mkdir`, `rename`, +`remove`, rekursives `rmdir`, `close` – für beide Protokolle. + +```bash +pip install pyftpdlib +python tests/test_backends.py # Beispieldatei (siehe Entwicklung/Skripte) +``` + +> Ein ausführbares Testskript kann ergänzt und an dieser Stelle dokumentiert +> werden; die Backends sind derart modular, dass sie ohne Terminal getestet +> werden können. + +--- + +## Lizenz + +MIT – siehe Projektträger/Repositories. Keine gewerblichen Einschränkungen. + +--- + +*Projekt wird innerhalb der `sysdaemon.xyz`-Instanz über Gitea verwaltet – +https://git.sysdaemon.xyz/admin/ftptui.* diff --git a/ftptui/__init__.py b/ftptui/__init__.py new file mode 100644 index 0000000..3d6ba91 --- /dev/null +++ b/ftptui/__init__.py @@ -0,0 +1,3 @@ +"""ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface.""" + +__version__ = "0.1.0" diff --git a/ftptui/app.py b/ftptui/app.py new file mode 100644 index 0000000..ee514ee --- /dev/null +++ b/ftptui/app.py @@ -0,0 +1,533 @@ +"""Haupt-TUI-Anwendung für ftptui. + +Basiert auf dem ``textual``-Framework und bietet einen +zweispaltigen Datei-Browser für lokal und entfernt, sowie +eine Verbindungsmaske für FTP und SFTP. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Input, + Label, + Select, + Static, +) + +from .backend import create_backend +from .config import Profile, load_profiles, save_profiles +from .models import FileTransferBackend, RemoteEntry + + +def _human_size(size: int) -> str: + for unit in ("B", "KiB", "MiB", "GiB"): + if size < 1024: + return f"{size:.0f} {unit}" + size /= 1024 + return f"{size:.2f} TiB" + + +def _parent(path: str, is_local: bool) -> str: + if is_local: + p = Path(path) + return str(p.parent) + if path in ("/", ""): + return "/" + return path.rstrip("/").rsplit("/", 1)[0] or "/" + + +def _join(path: str, name: str, is_local: bool) -> str: + if is_local: + return str(Path(path) / name) + if path in ("/", ""): + return "/" + name + return path.rstrip("/") + "/" + name + + +class ConnectionScreen(Screen): + """Startbildschirm zur Eingabe der Verbindungsdaten.""" + + BINDINGS = [Binding("escape", "quit", "Beenden")] + + def compose(self) -> ComposeResult: + yield Header() + yield Label( + "[bold cyan]ftptui[/] - FTP/SFTP Terminal-Client\n" + "----------------------------", + classes="title", + ) + yield Label("Verbindungsprofil:", classes="field") + yield Select( + [(p.name, p.name) for p in load_profiles()], id="profile", + classes="field", prompt="Neues Profil…", + ) + yield Label("Protokoll:", classes="field") + yield Select( + [("SFTP (SSH)", "sftp"), ("FTP", "ftp")], + value="sftp", id="protocol", classes="field", + ) + yield Label("Host:", classes="field") + yield Input(id="host", placeholder="git.sysdaemon.xyz") + yield Label("Port:", classes="field") + yield Input(id="port", placeholder="22 (SFTP) / 21 (FTP)", value="22") + yield Label("Benutzer:", classes="field") + yield Input(id="username", placeholder="user") + yield Label("Passwort:", classes="field") + yield Input(id="password", password=True) + yield Horizontal( + Button("Verbinden", variant="primary", id="connect"), + Button("Profil speichern", id="save_profile"), + Button("Profil laden", id="load_profile"), + Button("Verwerfen", id="reset", variant="error"), + classes="row", + ) + yield Static(id="conn_status") + yield Footer() + + @on(Select.Changed, "#protocol") + def _proto(self, event: Select.Changed) -> None: + self.query_one("#port", Input).value = "22" if event.value == "sftp" else "21" + + @on(Button.Pressed, "#connect") + def _connect(self) -> None: + proto = self.query_one("#protocol", Select).value + host = self.query_one("#host", Input).value.strip() + port_s = self.query_one("#port", Input).value.strip() + user = self.query_one("#username", Input).value.strip() + pwd = self.query_one("#password", Input).value + try: + port = int(port_s) if port_s else (22 if proto == "sftp" else 21) + except ValueError: + self.query_one("#conn_status", Static).update( + "[red]Ungültiger Port.[/]" + ) + return + if not host or not user: + self.query_one("#conn_status", Static).update( + "[red]Host und Benutzer sind Pflichtfelder.[/]" + ) + return + status = self.query_one("#conn_status", Static) + status.update("[yellow]Verbinde…[/]") + backend = create_backend(proto) + try: + backend.connect(host, port, user, pwd) + except Exception as exc: # noqa: BLE001 + status.update(f"[red]Fehler: {exc}[/]") + return + self.app.backend = backend + self.app.protocol = proto + self.app.set_screen(BrowserScreen.SCREEN_NAME) + status.update("") + + @on(Button.Pressed, "#save_profile") + def _save_profile(self) -> None: + profiles = load_profiles() + proto = self.query_one("#protocol", Select).value + profiles.append( + Profile( + name=self.query_one("#host", Input).value.strip(), + protocol=proto, + host=self.query_one("#host", Input).value.strip(), + port=int( + self.query_one("#port", Input).value or (22 if proto == "sftp" else 21) + ), + username=self.query_one("#username", Input).value.strip(), + password=self.query_one("#password", Input).value, + ) + ) + save_profiles(profiles) + self.query_one("#conn_status", Static).update( + "[green]Profil gespeichert.[/]" + ) + + @on(Button.Pressed, "#load_profile") + def _load_profile(self) -> None: + sel = self.query_one("#profile", Select) + if sel.value == Select.BLANK: + return + for p in load_profiles(): + if p.name == sel.value: + self.query_one("#protocol", Select).value = p.protocol + self.query_one("#host", Input).value = p.host + self.query_one("#port", Input).value = str(p.port) + self.query_one("#username", Input).value = p.username + self.query_one("#password", Input).value = p.password + self.query_one("#conn_status", Static).update( + "[green]Profil geladen.[/]" + ) + return + + +class BrowserScreen(Screen): + """Zweispaltiger Datei-Browser zwischen lokalem und entferntem System.""" + + SCREEN_NAME = "browser" + BINDINGS = [ + Binding("q", "quit", "Beenden"), + Binding("enter", "open", "Öffnen"), + Binding("tab", "focus_next", "Panele wechseln"), + Binding("l", "download", "Herunterladen"), + Binding("u", "upload", "Hochladen"), + Binding("n", "mkdir", "Verzeichnis"), + Binding("d", "delete", "Löschen"), + Binding("r", "rename", "Umbenennen"), + Binding("left", "up_level", "Eine Ebene hoch"), + Binding("space", "select", "Auswählen"), + ] + + def compose(self) -> ComposeResult: + yield Header() + yield Horizontal( + Vertical( + Static("Lokales System", classes="pane-title"), + DataTable(id="local_table", zebra_stripes=True), + classes="pane", + ), + Vertical( + Static("Entferntes System", classes="pane-title"), + DataTable(id="remote_table", zebra_stripes=True), + classes="pane", + ), + ) + yield Static(id="info", classes="info") + yield Footer() + + def on_mount(self) -> None: + self.local_path = str(Path.home()) + self.remote_path: str = "" + self.table_local = self.query_one("#local_table", DataTable) + self.table_remote = self.query_one("#remote_table", DataTable) + self.backend: FileTransferBackend = self.app.backend + self.table_local.add_columns("Name", "Größe", "Geändert", key="name") + self.table_remote.add_columns("Name", "Größe", "Geändert", key="name") + self._refresh_local() + self._refresh_remote() + + def _refresh_local(self) -> None: + self.table_local.clear() + entries: list[RemoteEntry] = [] + try: + for item in sorted(os.scandir(self.local_path), key=lambda e: (not e.is_dir(), e.name.lower())): + try: + st = item.stat() + kind = "dir" if item.is_dir() else "file" + entries.append( + RemoteEntry( + name=item.name, + kind=kind, + size=st.st_size, + modified=item.name, + ) + ) + except OSError: + continue + except OSError as exc: + self.query_one("#info", Static).update(f"[red]{exc}[/]") + return + self._fill(self.table_local, entries, local=True) + + def _refresh_remote(self) -> None: + self.table_remote.clear() + try: + if not self.remote_path: + self.remote_path = self.backend.pwd() + entries = self.backend.listdir(self.remote_path) + self.remote_path = self.backend.pwd() + except Exception as exc: # noqa: BLE001 + self.query_one("#info", Static).update(f"[red]{exc}[/]") + return + self._fill(self.table_remote, entries, local=False) + + def _fill(self, table: DataTable, entries: list[RemoteEntry], local: bool) -> None: + parent = _parent(self.local_path if local else self.remote_path, local) + table.add_row("\u2191 ..", "", "", key=parent) + for e in entries: + if e.name in (".", ".."): + continue + suffix = "/" if e.kind == "dir" else "" + table.add_row( + e.name + suffix, + _human_size(e.size) if e.kind == "file" else "