Compare commits
6 commits
8565ce19fe
...
5f155f6cb2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f155f6cb2 | |||
| 0c9a3a8527 | |||
| e3be433e37 | |||
| 7c0ef15567 | |||
| 5224fe78b6 | |||
| e310930d9e |
12 changed files with 613 additions and 67 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -6,3 +6,5 @@ uv.lock
|
|||
|
||||
__pycache__/
|
||||
**/dist/
|
||||
|
||||
dump/
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
23
app/main.py
23
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
66
app/static/css/style.css
Normal file
66
app/static/css/style.css
Normal file
|
|
@ -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
|
||||
}
|
||||
187
app/static/js/app.js
Normal file
187
app/static/js/app.js
Normal file
|
|
@ -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));
|
||||
|
|
@ -4,36 +4,63 @@
|
|||
<meta charset="utf-8"/>
|
||||
<title>{{ title }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0b5fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>To jest prosta strona Jinja2 serwowana przez FastAPI.</p>
|
||||
<ul>
|
||||
<li>UI OpenAPI: <a href="/docs">/docs</a></li>
|
||||
<li>OpenAPI JSON: <a href="/openapi.json">/openapi.json</a></li>
|
||||
<li>API v1: <a href="/api/v1/">/api/v1/</a></li>
|
||||
</ul>
|
||||
<p>Uruchamianie serwera lokalnie: <code>uv run app</code> lub <code>uv run uvicorn app.main:app --reload</code></p>
|
||||
<div class="layout">
|
||||
<div class="left">
|
||||
<div class="toolbar">
|
||||
<button id="btn-refresh">Odśwież</button>
|
||||
<button id="btn-add-group">Nowa grupa</button>
|
||||
<button id="btn-add-entry">Nowy wpis</button>
|
||||
</div>
|
||||
<div id="tree"></div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="toolbar">
|
||||
<select id="entry-kind">
|
||||
<option value="simple">Prosty (klucz:wartość)</option>
|
||||
<option value="complex">Złożony</option>
|
||||
</select>
|
||||
<button id="btn-save-all">Zapisz do bazy</button>
|
||||
</div>
|
||||
|
||||
<div class="section" id="editor-simple" style="display:none;">
|
||||
<div class="row"><label class="mono" style="width:140px;">Klucz</label><input id="s-key" type="text"/></div>
|
||||
<div class="row"><label class="mono" style="width:140px;">Wartość</label><input id="s-value"
|
||||
type="password"/></div>
|
||||
<div class="row" style="justify-content:flex-end; gap:8px;">
|
||||
<button id="s-save">Zapisz wpis</button>
|
||||
<button id="s-delete" class="danger">Usuń</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="editor-complex" style="display:none;">
|
||||
<div class="row"><label style="width:140px;">Tytuł</label><input id="c-title" type="text"/></div>
|
||||
<div class="row"><label style="width:140px;">Użytkownik</label><input id="c-username" type="text"/></div>
|
||||
<div class="row"><label style="width:140px;">Hasło</label><input id="c-password" type="password"/></div>
|
||||
<div class="row"><label style="width:140px;">URL</label><input id="c-url" type="url"/></div>
|
||||
<div class="row"><label style="width:140px;">Notatka</label><textarea id="c-notes" rows="5"></textarea>
|
||||
</div>
|
||||
<div class="row" style="justify-content:flex-end; gap:8px;">
|
||||
<button id="c-save">Zapisz wpis</button>
|
||||
<button id="c-delete" class="danger">Usuń</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="row">
|
||||
<label style="width:140px;">Docelowa grupa</label>
|
||||
<input id="target-group" type="text" placeholder="/Ścieżka/Grupy"/>
|
||||
<button id="btn-move-entry">Przenieś wpis</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
Aktualna ścieżka: <span id="current-path" class="mono">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
3
app/web/__init__.py
Normal file
3
app/web/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .passwords import PasswordsController
|
||||
|
||||
__all__ = ["PasswordsController"]
|
||||
128
app/web/passwords.py
Normal file
128
app/web/passwords.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue