diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index a8e15e5..6e5db5f 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -68,6 +68,7 @@ Manage the entire vault for a profile. | Action | Command | Examples | | :--- | :--- | :--- | | Export the vault | `vault export` | `seedpass vault export --file backup.json` | +| Import a vault | `vault import` | `seedpass vault import --file backup.json` | | Change the master password | `vault change-password` | `seedpass vault change-password` | ### Nostr Commands @@ -150,6 +151,7 @@ Code: 123456 ### `vault` Commands - **`seedpass vault export`** – Export the entire vault to an encrypted JSON file. +- **`seedpass vault import`** – Import a vault from an encrypted JSON file. - **`seedpass vault change-password`** – Change the master password used for encryption. ### `nostr` Commands diff --git a/docs/api_reference.md b/docs/api_reference.md index 4dedac7..7cc14e2 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -31,6 +31,7 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `POST /api/v1/checksum/verify` – Verify the checksum of the running script. - `POST /api/v1/checksum/update` – Update the stored script checksum. - `POST /api/v1/change-password` – Change the master password for the active profile. +- `POST /api/v1/vault/import` – Import a vault backup from a file or path. - `POST /api/v1/shutdown` – Stop the server gracefully. ## Example Requests diff --git a/src/requirements.txt b/src/requirements.txt index 3a8ee6e..1bfbef1 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -29,3 +29,4 @@ fastapi>=0.116.0 uvicorn>=0.35.0 httpx>=0.28.1 requests>=2.32 +python-multipart diff --git a/src/seedpass/api.py b/src/seedpass/api.py index a3231af..f05ba8e 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -3,10 +3,12 @@ from __future__ import annotations import os +import tempfile +from pathlib import Path import secrets from typing import Any, List, Optional -from fastapi import FastAPI, Header, HTTPException +from fastapi import FastAPI, Header, HTTPException, Request import asyncio import sys from fastapi.middleware.cors import CORSMiddleware @@ -329,6 +331,37 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]: return {"status": "ok"} +@app.post("/api/v1/vault/import") +async def import_vault( + request: Request, authorization: str | None = Header(None) +) -> dict[str, str]: + """Import a vault backup from a file upload or a server path.""" + _check_token(authorization) + assert _pm is not None + + ctype = request.headers.get("content-type", "") + if ctype.startswith("multipart/form-data"): + form = await request.form() + file = form.get("file") + if file is None: + raise HTTPException(status_code=400, detail="Missing file") + data = await file.read() + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(data) + tmp_path = Path(tmp.name) + try: + _pm.handle_import_database(tmp_path) + finally: + os.unlink(tmp_path) + else: + body = await request.json() + path = body.get("path") + if not path: + raise HTTPException(status_code=400, detail="Missing file or path") + _pm.handle_import_database(Path(path)) + return {"status": "ok"} + + @app.post("/api/v1/change-password") def change_password(authorization: str | None = Header(None)) -> dict[str, str]: """Change the master password for the active profile.""" diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 66a4359..3d72b8f 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -324,6 +324,16 @@ def vault_export( typer.echo(str(file)) +@vault_app.command("import") +def vault_import( + ctx: typer.Context, file: str = typer.Option(..., help="Input file") +) -> None: + """Import a vault from an encrypted JSON file.""" + pm = _get_pm(ctx) + pm.handle_import_database(Path(file)) + typer.echo(str(file)) + + @vault_app.command("change-password") def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 5606c0a..31ed814 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from pathlib import Path import pytest from seedpass import api @@ -161,3 +162,48 @@ def test_checksum_endpoints(client): assert res.status_code == 200 assert res.json() == {"status": "ok"} assert calls.get("update") is True + + +def test_vault_import_via_path(client, tmp_path): + cl, token = client + called = {} + + def import_db(path): + called["path"] = path + + api._pm.handle_import_database = import_db + file_path = tmp_path / "b.json" + file_path.write_text("{}") + + headers = {"Authorization": f"Bearer {token}"} + res = cl.post( + "/api/v1/vault/import", + json={"path": str(file_path)}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json() == {"status": "ok"} + assert called["path"] == file_path + + +def test_vault_import_via_upload(client, tmp_path): + cl, token = client + called = {} + + def import_db(path): + called["path"] = path + + api._pm.handle_import_database = import_db + file_path = tmp_path / "c.json" + file_path.write_text("{}") + + headers = {"Authorization": f"Bearer {token}"} + with open(file_path, "rb") as fh: + res = cl.post( + "/api/v1/vault/import", + files={"file": ("c.json", fh.read())}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json() == {"status": "ok"} + assert isinstance(called.get("path"), Path) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index d64c06c..0b8d73b 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -81,6 +81,23 @@ def test_vault_export(monkeypatch, tmp_path): assert called["path"] == out_path +def test_vault_import(monkeypatch, tmp_path): + called = {} + + def import_db(path): + called["path"] = path + + pm = SimpleNamespace( + handle_import_database=import_db, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + in_path = tmp_path / "in.json" + in_path.write_text("{}") + result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) + assert result.exit_code == 0 + assert called["path"] == in_path + + def test_vault_change_password(monkeypatch): called = {}