diff --git a/.gitignore b/.gitignore index b6f4f9b..8395e24 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ uv.lock __pycache__/ **/dist/ - -dump/ diff --git a/app/api/models.py b/app/api/models.py index ce7c5c0..35afedb 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -1,6 +1,5 @@ from dataclasses import dataclass -from enum import Enum -from typing import List, Optional +from typing import List @dataclass @@ -11,49 +10,6 @@ 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 b1785a7..fc52bd4 100644 --- a/app/api/v1.py +++ b/app/api/v1.py @@ -1,10 +1,6 @@ -from fastapi import APIRouter, Depends, Body, Query -from fastapi.responses import JSONResponse -from fastapi_utils.cbv import cbv -from injectable import inject +from fastapi import APIRouter -from app.api.models import * -from app.web import PasswordsController +from app.api.models import Request, Response router = APIRouter() @@ -18,92 +14,6 @@ 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 475be95..0d1a300 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -2,17 +2,19 @@ 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 = Jinja2Templates(directory="app/templates") +templates_env = Environment( + loader=FileSystemLoader("app/templates"), + autoescape=select_autoescape(["html", "xml"]), +) # Przykładowy endpoint HTML @router.get("/", response_class=HTMLResponse) async def index(request: Request) -> HTMLResponse: - return templates.TemplateResponse(name="index.html", - request=request, - context={"title": "Strona Główna"}) + template = templates_env.get_template("index.html") + html = template.render(title="Strona główna", request=request) + return HTMLResponse(content=html) diff --git a/app/main.py b/app/main.py index 03f6c34..5bf8c98 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,6 @@ 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 @@ -13,16 +12,15 @@ class KarlApplication: from starlette.types import Receive, Scope, Send def __init__(self) -> None: self._set_logging() - load_injection_container() - _instance = FastAPI(title="Karl", version="0.1.0") - self._set_routes(_instance) - self._set_events(_instance) + _app = FastAPI(title="Karl", version="0.1.0") + self._set_routes(_app) + self._set_events(_app) self._init_services() - self._instance = _instance + self._app = _app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - await self._instance.__call__(scope, receive, send) + await self._app.__call__(scope, receive, send) def _set_logging(self): logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()]) @@ -41,18 +39,19 @@ class KarlApplication: logging_logger.handlers = [external_handler] logging_logger.propagate = False - def _set_routes(self, instance: FastAPI): + def _set_routes(self, app: FastAPI): from app.core.router import router as core_router - instance.include_router(core_router) + app.include_router(core_router) from app.api.v1 import router as api_v1_router - instance.include_router(api_v1_router, prefix="/api/v1", tags=["v1"]) - instance.mount("/static", StaticFiles(directory="app/static"), name="static") + app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"]) + pass - def _set_events(self, instance: FastAPI): + def _set_events(self, app: 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 9b9412a..18d8519 100644 --- a/app/services/passwords.py +++ b/app/services/passwords.py @@ -2,7 +2,6 @@ import os.path from injectable import injectable from pykeepass import PyKeePass, create_database, Group -import shutil @injectable(singleton=True) @@ -12,28 +11,30 @@ class Passwords: settings = get_settings() with open(settings.kp.secret, "r") as fh: - 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) + secret = fh.read() + + self._kp_org = self.__get_or_create_store(settings.kp.file, secret) + self._kp = self.__get_lock(settings.kp.file, secret) @staticmethod - def _open_or_create(path, password) -> PyKeePass: + def __get_or_create_store(path, passwd) -> PyKeePass: if os.path.exists(path): - return PyKeePass(path, password=password) - return create_database(path, password) + return PyKeePass( + path, + password=passwd, + ) + return create_database(path, passwd) @staticmethod - def _open_lock(path, password) -> PyKeePass: + def __get_lock(path, passwd) -> PyKeePass: lock_path = path + ".lock" + import shutil shutil.copyfile(path, lock_path) - return Passwords._open_or_create(lock_path, password) + return Passwords.__get_or_create_store(lock_path, passwd) @property - def kp(self) -> PyKeePass: - return self._kp + def store(self): + return self._kp.root_group - def save(self): - # nadpisz plik źródłowy zmianami z lock - self._kp.save() - shutil.copyfile(self._path + ".lock", self._path) + def save(self, group: Group): + pass diff --git a/app/static/css/style.css b/app/static/css/style.css deleted file mode 100644 index 1cd3176..0000000 --- a/app/static/css/style.css +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 610f994..0000000 --- a/app/static/js/app.js +++ /dev/null @@ -1,187 +0,0 @@ -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 4e37ff3..ebbe8ca 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -4,63 +4,36 @@ {{ title }} - + -
-
-
- - - -
-
-
-
-
- - -
- - - - - -
-
- - - -
-
- -
- Aktualna ścieżka: / -
-
-
- +

{{ title }}

+

To jest prosta strona Jinja2 serwowana przez FastAPI.

+ +

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

diff --git a/app/web/__init__.py b/app/web/__init__.py deleted file mode 100644 index ba466bf..0000000 --- a/app/web/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .passwords import PasswordsController - -__all__ = ["PasswordsController"] diff --git a/app/web/passwords.py b/app/web/passwords.py deleted file mode 100644 index ec57742..0000000 --- a/app/web/passwords.py +++ /dev/null @@ -1,128 +0,0 @@ -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 072a9fc..c4e6d55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ 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",