Compare commits
No commits in common. "5f155f6cb266cf38d04ef7816c68be85d8c3bd26" and "8565ce19fec269a8aaa18866646b95e48a2b6d0c" have entirely different histories.
5f155f6cb2
...
8565ce19fe
12 changed files with 67 additions and 613 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -6,5 +6,3 @@ uv.lock
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
**/dist/
|
**/dist/
|
||||||
|
|
||||||
dump/
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from typing import List
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -11,49 +10,6 @@ class Request:
|
||||||
commit_url: str
|
commit_url: str
|
||||||
changelist: List[str]
|
changelist: List[str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Response:
|
class Response:
|
||||||
status: int
|
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,10 +1,6 @@
|
||||||
from fastapi import APIRouter, Depends, Body, Query
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from fastapi_utils.cbv import cbv
|
|
||||||
from injectable import inject
|
|
||||||
|
|
||||||
from app.api.models import *
|
from app.api.models import Request, Response
|
||||||
from app.web import PasswordsController
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -18,92 +14,6 @@ async def root():
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ci", summary="CI Webhook")
|
@router.post("/ci", summary="CI Webhook")
|
||||||
async def ci(request: Request):
|
async def ci(request: Request):
|
||||||
return Response(200)
|
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,17 +2,19 @@ from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Inicjalizacja Jinja2
|
# Inicjalizacja Jinja2
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates_env = Environment(
|
||||||
|
loader=FileSystemLoader("app/templates"),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Przykładowy endpoint HTML
|
# Przykładowy endpoint HTML
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request) -> HTMLResponse:
|
async def index(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(name="index.html",
|
template = templates_env.get_template("index.html")
|
||||||
request=request,
|
html = template.render(title="Strona główna", request=request)
|
||||||
context={"title": "Strona Główna"})
|
return HTMLResponse(content=html)
|
||||||
|
|
|
||||||
23
app/main.py
23
app/main.py
|
|
@ -2,7 +2,6 @@ import logging
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from injectable import load_injection_container
|
from injectable import load_injection_container
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.core import WebhookProcessor
|
from app.core.core import WebhookProcessor
|
||||||
|
|
@ -13,16 +12,15 @@ class KarlApplication:
|
||||||
from starlette.types import Receive, Scope, Send
|
from starlette.types import Receive, Scope, Send
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._set_logging()
|
self._set_logging()
|
||||||
load_injection_container()
|
_app = FastAPI(title="Karl", version="0.1.0")
|
||||||
_instance = FastAPI(title="Karl", version="0.1.0")
|
self._set_routes(_app)
|
||||||
self._set_routes(_instance)
|
self._set_events(_app)
|
||||||
self._set_events(_instance)
|
|
||||||
self._init_services()
|
self._init_services()
|
||||||
|
|
||||||
self._instance = _instance
|
self._app = _app
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
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):
|
def _set_logging(self):
|
||||||
logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()])
|
logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()])
|
||||||
|
|
@ -41,18 +39,19 @@ class KarlApplication:
|
||||||
logging_logger.handlers = [external_handler]
|
logging_logger.handlers = [external_handler]
|
||||||
logging_logger.propagate = False
|
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
|
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
|
from app.api.v1 import router as api_v1_router
|
||||||
instance.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
|
app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
|
||||||
instance.mount("/static", StaticFiles(directory="app/static"), name="static")
|
pass
|
||||||
|
|
||||||
def _set_events(self, instance: FastAPI):
|
def _set_events(self, app: FastAPI):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _init_services(self):
|
def _init_services(self):
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
load_injection_container()
|
||||||
webhook_service = WebhookProcessor()
|
webhook_service = WebhookProcessor()
|
||||||
logger.info(webhook_service.health)
|
logger.info(webhook_service.health)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import os.path
|
||||||
|
|
||||||
from injectable import injectable
|
from injectable import injectable
|
||||||
from pykeepass import PyKeePass, create_database, Group
|
from pykeepass import PyKeePass, create_database, Group
|
||||||
import shutil
|
|
||||||
|
|
||||||
|
|
||||||
@injectable(singleton=True)
|
@injectable(singleton=True)
|
||||||
|
|
@ -12,28 +11,30 @@ class Passwords:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
with open(settings.kp.secret, "r") as fh:
|
with open(settings.kp.secret, "r") as fh:
|
||||||
secret = fh.read().splitlines()[0]
|
secret = fh.read()
|
||||||
self._path = settings.kp.file
|
|
||||||
self._kp_org = self._open_or_create(self._path, secret)
|
self._kp_org = self.__get_or_create_store(settings.kp.file, secret)
|
||||||
self._kp = self._open_lock(self._path, secret)
|
self._kp = self.__get_lock(settings.kp.file, secret)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _open_or_create(path, password) -> PyKeePass:
|
def __get_or_create_store(path, passwd) -> PyKeePass:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return PyKeePass(path, password=password)
|
return PyKeePass(
|
||||||
return create_database(path, password)
|
path,
|
||||||
|
password=passwd,
|
||||||
|
)
|
||||||
|
return create_database(path, passwd)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _open_lock(path, password) -> PyKeePass:
|
def __get_lock(path, passwd) -> PyKeePass:
|
||||||
lock_path = path + ".lock"
|
lock_path = path + ".lock"
|
||||||
|
import shutil
|
||||||
shutil.copyfile(path, lock_path)
|
shutil.copyfile(path, lock_path)
|
||||||
return Passwords._open_or_create(lock_path, password)
|
return Passwords.__get_or_create_store(lock_path, passwd)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def kp(self) -> PyKeePass:
|
def store(self):
|
||||||
return self._kp
|
return self._kp.root_group
|
||||||
|
|
||||||
def save(self):
|
def save(self, group: Group):
|
||||||
# nadpisz plik źródłowy zmianami z lock
|
pass
|
||||||
self._kp.save()
|
|
||||||
shutil.copyfile(self._path + ".lock", self._path)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
|
|
@ -4,63 +4,36 @@
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<link rel="stylesheet" href="/static/css/style.css"/>
|
<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>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<h1>{{ title }}</h1>
|
||||||
<div class="left">
|
<p>To jest prosta strona Jinja2 serwowana przez FastAPI.</p>
|
||||||
<div class="toolbar">
|
<ul>
|
||||||
<button id="btn-refresh">Odśwież</button>
|
<li>UI OpenAPI: <a href="/docs">/docs</a></li>
|
||||||
<button id="btn-add-group">Nowa grupa</button>
|
<li>OpenAPI JSON: <a href="/openapi.json">/openapi.json</a></li>
|
||||||
<button id="btn-add-entry">Nowy wpis</button>
|
<li>API v1: <a href="/api/v1/">/api/v1/</a></li>
|
||||||
</div>
|
</ul>
|
||||||
<div id="tree"></div>
|
<p>Uruchamianie serwera lokalnie: <code>uv run app</code> lub <code>uv run uvicorn app.main:app --reload</code></p>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from .passwords import PasswordsController
|
|
||||||
|
|
||||||
__all__ = ["PasswordsController"]
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -7,7 +7,6 @@ requires-python = ">=3.12"
|
||||||
authors = [{ name = "Piotr Dec" }]
|
authors = [{ name = "Piotr Dec" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.119.0",
|
"fastapi>=0.119.0",
|
||||||
"fastapi-utils>=0.8.0",
|
|
||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
"jinja2>=3.1.4",
|
"jinja2>=3.1.4",
|
||||||
"pydantic-settings>=2.4.0",
|
"pydantic-settings>=2.4.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue