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__/
|
__pycache__/
|
||||||
**/dist/
|
**/dist/
|
||||||
|
|
||||||
|
dump/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -10,6 +11,49 @@ 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,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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -14,6 +18,92 @@ 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,19 +2,17 @@ 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_env = Environment(
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
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:
|
||||||
template = templates_env.get_template("index.html")
|
return templates.TemplateResponse(name="index.html",
|
||||||
html = template.render(title="Strona główna", request=request)
|
request=request,
|
||||||
return HTMLResponse(content=html)
|
context={"title": "Strona Główna"})
|
||||||
|
|
|
||||||
23
app/main.py
23
app/main.py
|
|
@ -2,6 +2,7 @@ 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
|
||||||
|
|
@ -12,15 +13,16 @@ 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()
|
||||||
_app = FastAPI(title="Karl", version="0.1.0")
|
load_injection_container()
|
||||||
self._set_routes(_app)
|
_instance = FastAPI(title="Karl", version="0.1.0")
|
||||||
self._set_events(_app)
|
self._set_routes(_instance)
|
||||||
|
self._set_events(_instance)
|
||||||
self._init_services()
|
self._init_services()
|
||||||
|
|
||||||
self._app = _app
|
self._instance = _instance
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
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):
|
def _set_logging(self):
|
||||||
logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()])
|
logging.basicConfig(level=logging.INFO, handlers=[LoggingHandler()])
|
||||||
|
|
@ -39,19 +41,18 @@ class KarlApplication:
|
||||||
logging_logger.handlers = [external_handler]
|
logging_logger.handlers = [external_handler]
|
||||||
logging_logger.propagate = False
|
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
|
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
|
from app.api.v1 import router as api_v1_router
|
||||||
app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
|
instance.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
|
||||||
pass
|
instance.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
def _set_events(self, app: FastAPI):
|
def _set_events(self, instance: 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,6 +2,7 @@ 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)
|
||||||
|
|
@ -11,30 +12,28 @@ 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()
|
secret = fh.read().splitlines()[0]
|
||||||
|
self._path = settings.kp.file
|
||||||
self._kp_org = self.__get_or_create_store(settings.kp.file, secret)
|
self._kp_org = self._open_or_create(self._path, secret)
|
||||||
self._kp = self.__get_lock(settings.kp.file, secret)
|
self._kp = self._open_lock(self._path, secret)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_or_create_store(path, passwd) -> PyKeePass:
|
def _open_or_create(path, password) -> PyKeePass:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return PyKeePass(
|
return PyKeePass(path, password=password)
|
||||||
path,
|
return create_database(path, password)
|
||||||
password=passwd,
|
|
||||||
)
|
|
||||||
return create_database(path, passwd)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_lock(path, passwd) -> PyKeePass:
|
def _open_lock(path, password) -> PyKeePass:
|
||||||
lock_path = path + ".lock"
|
lock_path = path + ".lock"
|
||||||
import shutil
|
|
||||||
shutil.copyfile(path, lock_path)
|
shutil.copyfile(path, lock_path)
|
||||||
return Passwords.__get_or_create_store(lock_path, passwd)
|
return Passwords._open_or_create(lock_path, password)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def store(self):
|
def kp(self) -> PyKeePass:
|
||||||
return self._kp.root_group
|
return self._kp
|
||||||
|
|
||||||
def save(self, group: Group):
|
def save(self):
|
||||||
pass
|
# 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"/>
|
<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"/>
|
||||||
<style>
|
<link rel="stylesheet" href="/static/css/style.css"/>
|
||||||
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>
|
||||||
<h1>{{ title }}</h1>
|
<div class="layout">
|
||||||
<p>To jest prosta strona Jinja2 serwowana przez FastAPI.</p>
|
<div class="left">
|
||||||
<ul>
|
<div class="toolbar">
|
||||||
<li>UI OpenAPI: <a href="/docs">/docs</a></li>
|
<button id="btn-refresh">Odśwież</button>
|
||||||
<li>OpenAPI JSON: <a href="/openapi.json">/openapi.json</a></li>
|
<button id="btn-add-group">Nowa grupa</button>
|
||||||
<li>API v1: <a href="/api/v1/">/api/v1/</a></li>
|
<button id="btn-add-entry">Nowy wpis</button>
|
||||||
</ul>
|
</div>
|
||||||
<p>Uruchamianie serwera lokalnie: <code>uv run app</code> lub <code>uv run uvicorn app.main:app --reload</code></p>
|
<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>
|
</body>
|
||||||
</html>
|
</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" }]
|
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