diff --git a/.gitignore b/.gitignore index 8395e24..b6f4f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ uv.lock __pycache__/ **/dist/ + +dump/ diff --git a/app/api/models.py b/app/api/models.py index 35afedb..ce7c5c0 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import List +from enum import Enum +from typing import List, Optional @dataclass @@ -10,6 +11,49 @@ class Request: commit_url: str changelist: List[str] + @dataclass class Response: status: int + + +class EntryKind(str, Enum): + simple = "simple" + complex = "complex" + + +@dataclass +class EntrySimpleDTO: + key: str + value: str # mapowane na title/password + + +@dataclass +class EntryComplexDTO: + title: str + username: str + password: str + url: Optional[str] = None + notes: Optional[str] = None + + +@dataclass +class GroupDTO: + name: str + path: str + + +@dataclass +class EntryNodeDTO: + kind: EntryKind + title: str + path: str + + +@dataclass +class NodeDTO: + # Drzewo: grupa lub wpisy w grupach + name: str + path: str + groups: List["NodeDTO"] + entries: List[EntryNodeDTO] diff --git a/app/api/v1.py b/app/api/v1.py index fc52bd4..b1785a7 100644 --- a/app/api/v1.py +++ b/app/api/v1.py @@ -1,6 +1,10 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Body, Query +from fastapi.responses import JSONResponse +from fastapi_utils.cbv import cbv +from injectable import inject -from app.api.models import Request, Response +from app.api.models import * +from app.web import PasswordsController router = APIRouter() @@ -14,6 +18,92 @@ async def root(): async def health(): return {"status": "ok"} + @router.post("/ci", summary="CI Webhook") async def ci(request: Request): return Response(200) + +class AutowireSupport: + @staticmethod + def password_controller(): + return inject(PasswordsController) + + +@cbv(router) +class TreeController: + svc: PasswordsController = Depends(AutowireSupport.password_controller) + + @router.get("/tree", response_model=NodeDTO) + def get_tree(self) -> NodeDTO: + return self.svc.get_tree() + + @router.post("/group", response_model=GroupDTO) + def create_group( + self, + name: str = Body(..., embed=True), + parent_path: str | None = Body(None, embed=True), + ) -> GroupDTO: + return self.svc.create_group(name=name, parent_path=parent_path) + + @router.patch("/group/rename", response_model=GroupDTO) + def rename_group( + self, + path: str = Body(..., embed=True), + new_name: str = Body(..., embed=True), + ) -> GroupDTO: + return self.svc.rename_group(path, new_name) + + @router.delete("/group") + def delete_group( + self, + path: str = Query(...), + ): + self.svc.delete_group(path) + return JSONResponse({"ok": True}) + + @router.post("/entry", response_model=NodeDTO) + def create_entry( + self, + kind: EntryKind = Body(..., embed=True), + parent_path: str = Body(..., embed=True), + data: dict = Body(..., embed=True), + ) -> NodeDTO: + if kind == EntryKind.simple: + dto = EntrySimpleDTO(**data) + return self.svc.create_entry_simple(parent_path, dto) + dto = EntryComplexDTO(**data) + return self.svc.create_entry_complex(parent_path, dto) + + @router.patch("/entry", response_model=NodeDTO) + def update_entry( + self, + path: str = Body(..., embed=True), + kind: EntryKind = Body(..., embed=True), + data: dict = Body(..., embed=True), + ) -> NodeDTO: + if kind == EntryKind.simple: + dto = EntrySimpleDTO(**data) + return self.svc.update_entry_simple(path, dto) + dto = EntryComplexDTO(**data) + return self.svc.update_entry_complex(path, dto) + + @router.patch("/entry/move", response_model=NodeDTO) + def move_entry( + self, + path: str = Body(..., embed=True), + target_group_path: str = Body(..., embed=True), + ) -> NodeDTO: + return self.svc.move_entry(path, target_group_path) + + @router.delete("/entry") + def delete_entry( + self, + path: str = Query(...), + ): + self.svc.delete_entry(path) + return JSONResponse({"ok": True}) + + @router.post("/save") + def save_changes(self): + self.svc.save() + return JSONResponse({"ok": True}) diff --git a/app/core/router.py b/app/core/router.py index 0d1a300..475be95 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -2,19 +2,17 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi.templating import Jinja2Templates router = APIRouter() # Inicjalizacja Jinja2 -templates_env = Environment( - loader=FileSystemLoader("app/templates"), - autoescape=select_autoescape(["html", "xml"]), -) +templates = Jinja2Templates(directory="app/templates") # Przykładowy endpoint HTML @router.get("/", response_class=HTMLResponse) async def index(request: Request) -> HTMLResponse: - template = templates_env.get_template("index.html") - html = template.render(title="Strona główna", request=request) - return HTMLResponse(content=html) + return templates.TemplateResponse(name="index.html", + request=request, + context={"title": "Strona Główna"}) diff --git a/app/main.py b/app/main.py index 5bf8c98..03f6c34 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ import logging from fastapi import FastAPI from injectable import load_injection_container +from fastapi.staticfiles import StaticFiles from app.config import get_settings from app.core.core import WebhookProcessor @@ -12,15 +13,16 @@ class KarlApplication: from starlette.types import Receive, Scope, Send def __init__(self) -> None: self._set_logging() - _app = FastAPI(title="Karl", version="0.1.0") - self._set_routes(_app) - self._set_events(_app) + load_injection_container() + _instance = FastAPI(title="Karl", version="0.1.0") + self._set_routes(_instance) + self._set_events(_instance) self._init_services() - self._app = _app + self._instance = _instance async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - await self._app.__call__(scope, receive, send) + await self._instance.__call__(scope, receive, send) def _set_logging(self): logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()]) @@ -39,19 +41,18 @@ class KarlApplication: logging_logger.handlers = [external_handler] logging_logger.propagate = False - def _set_routes(self, app: FastAPI): + def _set_routes(self, instance: FastAPI): from app.core.router import router as core_router - app.include_router(core_router) + instance.include_router(core_router) from app.api.v1 import router as api_v1_router - app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"]) - pass + instance.include_router(api_v1_router, prefix="/api/v1", tags=["v1"]) + instance.mount("/static", StaticFiles(directory="app/static"), name="static") - def _set_events(self, app: FastAPI): + def _set_events(self, instance: FastAPI): pass def _init_services(self): logger = logging.getLogger(__name__) - load_injection_container() webhook_service = WebhookProcessor() logger.info(webhook_service.health) diff --git a/app/services/passwords.py b/app/services/passwords.py index 18d8519..9b9412a 100644 --- a/app/services/passwords.py +++ b/app/services/passwords.py @@ -2,6 +2,7 @@ import os.path from injectable import injectable from pykeepass import PyKeePass, create_database, Group +import shutil @injectable(singleton=True) @@ -11,30 +12,28 @@ class Passwords: settings = get_settings() with open(settings.kp.secret, "r") as fh: - secret = fh.read() - - self._kp_org = self.__get_or_create_store(settings.kp.file, secret) - self._kp = self.__get_lock(settings.kp.file, secret) + secret = fh.read().splitlines()[0] + self._path = settings.kp.file + self._kp_org = self._open_or_create(self._path, secret) + self._kp = self._open_lock(self._path, secret) @staticmethod - def __get_or_create_store(path, passwd) -> PyKeePass: + def _open_or_create(path, password) -> PyKeePass: if os.path.exists(path): - return PyKeePass( - path, - password=passwd, - ) - return create_database(path, passwd) + return PyKeePass(path, password=password) + return create_database(path, password) @staticmethod - def __get_lock(path, passwd) -> PyKeePass: + def _open_lock(path, password) -> PyKeePass: lock_path = path + ".lock" - import shutil shutil.copyfile(path, lock_path) - return Passwords.__get_or_create_store(lock_path, passwd) + return Passwords._open_or_create(lock_path, password) @property - def store(self): - return self._kp.root_group + def kp(self) -> PyKeePass: + return self._kp - def save(self, group: Group): - pass + def save(self): + # nadpisz plik źródłowy zmianami z lock + self._kp.save() + shutil.copyfile(self._path + ".lock", self._path) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..1cd3176 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,66 @@ +html, body { + height: 100%; + margin: 0; + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial; +} + +.layout { + display: grid; + grid-template-columns: 320px 1fr; + height: 100vh; +} + +.left { + border-right: 1px solid #ddd; + padding: 12px; + overflow: auto; +} + +.right { + padding: 12px; + overflow: auto; +} + +ul { + list-style: none; + padding-left: 16px; +} + +.node { + cursor: pointer; +} + +.toolbar { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.row { + margin-bottom: 8px; + display: flex; + gap: 8px; + align-items: center; +} + +input[type="text"], input[type="url"], input[type="password"], textarea, select { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 6px; +} + +.section { + border: 1px solid #eee; + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; +} + +.danger { + color: #b10000 +} diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..610f994 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,187 @@ +let selectedNode = { type: "group", path: "/" }; // type: "group" | "entry" +let currentKind = "simple"; + +const treeDiv = document.getElementById("tree"); +const currentPathEl = document.getElementById("current-path"); + +const editorSimple = document.getElementById("editor-simple"); +const editorComplex = document.getElementById("editor-complex"); +const kindSelect = document.getElementById("entry-kind"); + +function showEditor(kind) { + editorSimple.style.display = (kind === "simple") ? "block" : "none"; + editorComplex.style.display = (kind === "complex") ? "block" : "none"; +} + +kindSelect.addEventListener("change", () => { + currentKind = kindSelect.value; + showEditor(currentKind); +}); + +async function api(path, opts = {}) { + const resp = await fetch(path, opts); + if (!resp.ok) { + const txt = await resp.text(); + throw new Error("API error: " + txt); + } + if (resp.headers.get("Content-Type")?.includes("application/json")) { + return resp.json(); + } + return null; +} + +function renderTree(node) { + const ul = document.createElement("ul"); + + const mkEntryLi = (e) => { + const li = document.createElement("li"); + li.textContent = "🔑 " + e.title; + li.className = "node"; + li.onclick = () => { + selectedNode = { type: "entry", path: e.path, kind: e.kind }; + currentPathEl.textContent = e.path; + kindSelect.value = e.kind; + showEditor(e.kind); + if (e.kind === "simple") { + document.getElementById("s-key").value = e.title; + document.getElementById("s-value").value = ""; + } else { + document.getElementById("c-title").value = e.title; + document.getElementById("c-username").value = ""; + document.getElementById("c-password").value = ""; + document.getElementById("c-url").value = ""; + document.getElementById("c-notes").value = ""; + } + }; + return li; + }; + + const mkGroupLi = (g) => { + const li = document.createElement("li"); + const header = document.createElement("div"); + header.textContent = "📂 " + (g.name || "/"); + header.className = "node"; + header.onclick = () => { + selectedNode = { type: "group", path: g.path }; + currentPathEl.textContent = g.path; + }; + li.appendChild(header); + + const inner = renderTreeChildren(g); + li.appendChild(inner); + return li; + }; + + function renderTreeChildren(node) { + const wrap = document.createElement("ul"); + node.groups.forEach(sg => wrap.appendChild(mkGroupLi(sg))); + node.entries.forEach(e => wrap.appendChild(mkEntryLi(e))); + return wrap; + } + + ul.appendChild(mkGroupLi(node)); + treeDiv.innerHTML = ""; + treeDiv.appendChild(ul); +} + +async function refreshTree() { + const data = await api("/api/v1/tree"); + renderTree(data); +} + +// Toolbar lewej kolumny +document.getElementById("btn-refresh").onclick = refreshTree; + +document.getElementById("btn-add-group").onclick = async () => { + const name = prompt("Nazwa nowej grupy:"); + if (!name) return; + await api("/api/v1/group", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ name, parent_path: selectedNode.type === "group" ? selectedNode.path : "/" }) + }); + await refreshTree(); +}; + +document.getElementById("btn-add-entry").onclick = async () => { + const parent_path = selectedNode.type === "group" ? selectedNode.path : "/"; + const kind = kindSelect.value; + if (kind === "simple") { + const key = prompt("Klucz (title):"); if (!key) return; + const value = prompt("Wartość (password):"); if (value === null) return; + await api("/api/v1/entry", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ kind, parent_path, data: { key, value } }) + }); + } else { + const title = prompt("Tytuł:"); if (!title) return; + await api("/api/v1/entry", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ kind, parent_path, data: { title, username:"", password:"", url:"", notes:"" } }) + }); + } + await refreshTree(); +}; + +// Edytory +document.getElementById("s-save").onclick = async () => { + if (selectedNode.type !== "entry") { alert("Wybierz wpis"); return; } + const data = { key: document.getElementById("s-key").value, value: document.getElementById("s-value").value }; + await api("/api/v1/entry", { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ path: selectedNode.path, kind: "simple", data }) + }); + await refreshTree(); +}; +document.getElementById("s-delete").onclick = async () => { + if (selectedNode.type !== "entry") return; + if (!confirm("Usunąć wpis?")) return; + await api("/api/v1/entry?path=" + encodeURIComponent(selectedNode.path), { method: "DELETE" }); + await refreshTree(); +}; + +document.getElementById("c-save").onclick = async () => { + if (selectedNode.type !== "entry") { alert("Wybierz wpis"); return; } + const data = { + title: document.getElementById("c-title").value, + username: document.getElementById("c-username").value, + password: document.getElementById("c-password").value, + url: document.getElementById("c-url").value, + notes: document.getElementById("c-notes").value + }; + await api("/api/v1/entry", { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ path: selectedNode.path, kind: "complex", data }) + }); + await refreshTree(); +}; +document.getElementById("c-delete").onclick = async () => { + if (selectedNode.type !== "entry") return; + if (!confirm("Usunąć wpis?")) return; + await api("/api/v1/entry?path=" + encodeURIComponent(selectedNode.path), { method: "DELETE" }); + await refreshTree(); +}; + +document.getElementById("btn-move-entry").onclick = async () => { + if (selectedNode.type !== "entry") { alert("Wybierz wpis"); return; } + const target = document.getElementById("target-group").value || "/"; + await api("/api/v1/entry/move", { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ path: selectedNode.path, target_group_path: target }) + }); + await refreshTree(); +}; + +document.getElementById("btn-save-all").onclick = async () => { + await api("/api/v1/save", { method: "POST" }); + alert("Zapisano do bazy"); +}; + +// Start +showEditor(currentKind); +refreshTree().catch(err => console.error(err)); diff --git a/app/templates/index.html b/app/templates/index.html index ebbe8ca..4e37ff3 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -4,36 +4,63 @@ {{ title }} - + -

{{ title }}

-

To jest prosta strona Jinja2 serwowana przez FastAPI.

- -

Uruchamianie serwera lokalnie: uv run app lub uv run uvicorn app.main:app --reload

+
+
+
+ + + +
+
+
+
+
+ + +
+ + + + + +
+
+ + + +
+
+ +
+ Aktualna ścieżka: / +
+
+
+ diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..ba466bf --- /dev/null +++ b/app/web/__init__.py @@ -0,0 +1,3 @@ +from .passwords import PasswordsController + +__all__ = ["PasswordsController"] diff --git a/app/web/passwords.py b/app/web/passwords.py new file mode 100644 index 0000000..ec57742 --- /dev/null +++ b/app/web/passwords.py @@ -0,0 +1,128 @@ +from typing import Optional, Annotated + +from injectable import autowired, injectable, Autowired +from pykeepass import Group, Entry + +from app.api.models import EntryKind, EntrySimpleDTO, EntryComplexDTO, GroupDTO, NodeDTO, EntryNodeDTO +from app.services import Passwords + + +@injectable(singleton=True) +class PasswordsController: + @autowired + def __init__(self, passwords: Annotated[Passwords, Autowired]): + self._pw = passwords + + # Helpers + def _group_by_path(self, path: Optional[str]) -> Group: + if not path or path == "/": + return self._pw.kp.root_group + parts = [p for p in path.split("/") if p] + g = self._pw.kp.root_group + for p in parts: + g = next((x for x in g.subgroups if x.name == p), None) + if g is None: + raise ValueError(f"Group not found: {path}") + return g + + def _ensure_group(self, parent: Group, name: str) -> Group: + g = next((x for x in parent.subgroups if x.name == name), None) + if g: + return g + return self._pw.kp.add_group(parent, name) + + def _entry_by_path(self, path: str) -> Entry: + # path: /Group1/Sub/Title + parts = [p for p in path.split("/") if p] + if not parts: + raise ValueError("Invalid entry path") + title = parts[-1] + group_path = "/" + "/".join(parts[:-1]) if len(parts) > 1 else "/" + g = self._group_by_path(group_path) + e = next((x for x in g.entries if x.title == title), None) + if not e: + raise ValueError(f"Entry not found: {path}") + return e + + def _node_of_group(self, g: Group) -> NodeDTO: + raw_path = g.path + path = "/" + "/".join(raw_path) if raw_path else "/" + groups = [self._node_of_group(sg) for sg in g.subgroups] + entries = [EntryNodeDTO( + kind=EntryKind.complex if (e.username or e.url or e.notes) else EntryKind.simple, + title=e.title, + path=path.rstrip("/") + "/" + e.title + ) for e in g.entries] + return NodeDTO(name=g.name or "", path=path, groups=groups, entries=entries) + + # Tree + def get_tree(self) -> NodeDTO: + return self._node_of_group(self._pw.kp.root_group) + + # Groups + def create_group(self, name: str, parent_path: Optional[str]) -> GroupDTO: + parent = self._group_by_path(parent_path) + g = self._ensure_group(parent, name) + return GroupDTO(name=g.name, path=self._node_of_group(g).path) + + def rename_group(self, path: str, new_name: str) -> GroupDTO: + g = self._group_by_path(path) + g.name = new_name + return GroupDTO(name=g.name, path=self._node_of_group(g).path) + + def delete_group(self, path: str) -> None: + g = self._group_by_path(path) + if g is self._pw.kp.root_group: + raise ValueError("Cannot delete root group") + self._pw.kp.delete_group(g) + + # Entries + def create_entry_simple(self, parent_path: str, dto: EntrySimpleDTO) -> NodeDTO: + parent = self._group_by_path(parent_path) + title = dto.key + password = dto.value + self._pw.kp.add_entry(parent, title=title, username="", password=password) + return self._node_of_group(parent) + + def create_entry_complex(self, parent_path: str, dto: EntryComplexDTO) -> NodeDTO: + parent = self._group_by_path(parent_path) + self._pw.kp.add_entry( + parent, + title=dto.title, + username=dto.username or "", + password=dto.password or "", + url=dto.url or "", + notes=dto.notes or "", + ) + return self._node_of_group(parent) + + def update_entry_simple(self, path: str, dto: EntrySimpleDTO) -> NodeDTO: + e = self._entry_by_path(path) + parent = e.group + e.title = dto.key + e.password = dto.value + return self._node_of_group(parent) + + def update_entry_complex(self, path: str, dto: EntryComplexDTO) -> NodeDTO: + e = self._entry_by_path(path) + parent = e.group + e.title = dto.title + e.username = dto.username + e.password = dto.password + e.url = dto.url or "" + e.notes = dto.notes or "" + return self._node_of_group(parent) + + def move_entry(self, path: str, target_group_path: str) -> NodeDTO: + e = self._entry_by_path(path) + target = self._group_by_path(target_group_path) + self._pw.kp.move_entry(e, target) + return self._node_of_group(target) + + def delete_entry(self, path: str) -> None: + e = self._entry_by_path(path) + self._pw.kp.delete_entry(e) + + # Persist + def save(self): + self._pw.save() diff --git a/pyproject.toml b/pyproject.toml index c4e6d55..072a9fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" authors = [{ name = "Piotr Dec" }] dependencies = [ "fastapi>=0.119.0", + "fastapi-utils>=0.8.0", "uvicorn[standard]>=0.30.0", "jinja2>=3.1.4", "pydantic-settings>=2.4.0",