snotes updates, actions, attachments, easy user space
This commit is contained in:
75
backend/actions.py
Normal file
75
backend/actions.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
ACTION_LINE_RE = re.compile(r"^/(?P<kw>[A-Za-z0-9_-]+)\s*:?\s*(?P<rest>.*)$")
|
||||
|
||||
_actions_cache: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def load_actions(reload: bool = False) -> dict[str, dict]:
|
||||
global _actions_cache
|
||||
if _actions_cache is not None and not reload:
|
||||
return _actions_cache
|
||||
|
||||
actions: dict[str, dict] = {}
|
||||
path = Path(settings.actions_file)
|
||||
if path.is_file():
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
for item in data.get("actions", []):
|
||||
name = item.get("name")
|
||||
uri = item.get("uri")
|
||||
if name and uri:
|
||||
actions[name] = {"uri": uri, "link": item.get("link", uri)}
|
||||
_actions_cache = actions
|
||||
return actions
|
||||
|
||||
|
||||
async def _perform(uri: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(uri)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("action_failed", uri=uri)
|
||||
return False
|
||||
|
||||
|
||||
async def process_actions(body: str) -> str:
|
||||
actions = load_actions()
|
||||
if not actions:
|
||||
return body
|
||||
|
||||
out_lines: list[str] = []
|
||||
for line in body.split("\n"):
|
||||
m = ACTION_LINE_RE.match(line)
|
||||
action = actions.get(m.group("kw")) if m else None
|
||||
if action is None:
|
||||
out_lines.append(line)
|
||||
continue
|
||||
|
||||
rest = m.group("rest").strip()
|
||||
if not rest:
|
||||
out_lines.append(line)
|
||||
continue
|
||||
|
||||
encoded = quote(rest)
|
||||
uri = action["uri"].replace("#string1#", encoded)
|
||||
link = action["link"].replace("#string1#", encoded)
|
||||
if await _perform(uri):
|
||||
out_lines.append(f"[{rest}]({link})")
|
||||
else:
|
||||
out_lines.append(line)
|
||||
|
||||
return "\n".join(out_lines)
|
||||
@@ -16,6 +16,9 @@ class Settings(BaseSettings):
|
||||
# SQLite metadata + search index (local disk)
|
||||
database_path: str = "snotes.db"
|
||||
|
||||
# TOML file defining slash "actions" (see snotes.toml)
|
||||
actions_file: str = "snotes.toml"
|
||||
|
||||
# Fixed list of usernames, comma-separated (seeded at startup)
|
||||
users: str = "schmeeve,ilya"
|
||||
|
||||
@@ -29,7 +32,7 @@ class Settings(BaseSettings):
|
||||
secret_key: str = "change-me-in-production"
|
||||
|
||||
# Subnet(s) considered "local" and allowed in without a password
|
||||
lan_cidrs: str = "192.168.1.0/24"
|
||||
lan_cidrs: str = "192.168.1.0/24,127.0.0.1/32,::1/128"
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
@@ -69,8 +69,9 @@ async def init_db() -> None:
|
||||
|
||||
|
||||
async def seed_users() -> None:
|
||||
names = list(dict.fromkeys([*settings.user_list, settings.common_space]))
|
||||
async with aiosqlite.connect(settings.database_path) as db:
|
||||
for name in settings.user_list:
|
||||
for name in names:
|
||||
await db.execute("INSERT OR IGNORE INTO users (name) VALUES (?)", (name,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -49,6 +49,12 @@ def current_user(request: Request) -> str:
|
||||
return username or settings.user_list[0]
|
||||
|
||||
|
||||
def valid_username(username: str) -> str:
|
||||
if username not in settings.user_list and username != settings.common_space:
|
||||
raise HTTPException(status_code=404, detail="unknown user")
|
||||
return username
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA + static
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -61,7 +67,7 @@ async def index() -> FileResponse:
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> FileResponse:
|
||||
return FileResponse("static/snotes-main.png")
|
||||
return FileResponse("static/snotes-new.png")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -114,6 +120,8 @@ async def login(body: LoginRequest) -> Response:
|
||||
@app.post("/api/notes")
|
||||
async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)):
|
||||
username = current_user(request)
|
||||
if body.username:
|
||||
username = valid_username(body.username)
|
||||
if not body.body.strip():
|
||||
raise HTTPException(status_code=400, detail="empty note")
|
||||
try:
|
||||
@@ -148,7 +156,13 @@ async def get_note(note_id: str, _: None = Depends(require_access)):
|
||||
async def update_note(
|
||||
note_id: str, body: NoteUpdate, request: Request, _: None = Depends(require_access)
|
||||
):
|
||||
note = await notes.update_note(note_id, body.body, ip=client_ip(request), ua=client_ua(request))
|
||||
note = await notes.update_note(
|
||||
note_id,
|
||||
body.body,
|
||||
ip=client_ip(request),
|
||||
ua=client_ua(request),
|
||||
username=valid_username(body.username) if body.username else None,
|
||||
)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="note not found")
|
||||
return note
|
||||
@@ -168,10 +182,45 @@ async def note_history(note_id: str, _: None = Depends(require_access)):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags / search
|
||||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/attachments")
|
||||
async def upload_attachment(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
username: str | None = Form(None),
|
||||
_: None = Depends(require_access),
|
||||
):
|
||||
owner = current_user(request)
|
||||
if username:
|
||||
owner = valid_username(username)
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="empty file")
|
||||
filename = await notes.store_attachment(owner, file.filename or "file", data)
|
||||
return {
|
||||
"url": f"/api/attachments/{owner}/{filename}",
|
||||
"name": file.filename or filename,
|
||||
"path": f"{owner}/attachments/{filename}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/attachments/{username}/{filename}")
|
||||
async def get_attachment(username: str, filename: str, _: None = Depends(require_access)):
|
||||
from pathlib import Path
|
||||
|
||||
full = Path(settings.data_dir) / username / "attachments" / filename
|
||||
if not full.is_file():
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(full)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags / search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/tags")
|
||||
async def tags(_: None = Depends(require_access)):
|
||||
return await notes.tag_counts()
|
||||
|
||||
@@ -9,10 +9,12 @@ class UserSelect(BaseModel):
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
body: str
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
body: str
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from backend import actions
|
||||
from backend.config import settings
|
||||
from backend.database import get_user_id
|
||||
|
||||
@@ -75,6 +76,17 @@ def new_note_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
async def store_attachment(username: str, original_name: str, data: bytes) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
ext = re.sub(r"[^A-Za-z0-9.]", "", Path(original_name or "").suffix)[:16]
|
||||
filename = new_note_id() + ext
|
||||
full = Path(settings.data_dir) / username / "attachments" / filename
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_bytes(data)
|
||||
return filename
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Note CRUD (metadata + tags + FTS)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -86,6 +98,7 @@ async def create_note(
|
||||
ip: str | None = None,
|
||||
ua: str | None = None,
|
||||
) -> dict:
|
||||
body = await actions.process_actions(body)
|
||||
title = extract_title(body)
|
||||
tags = extract_tags(body)
|
||||
note_id = new_note_id()
|
||||
@@ -123,7 +136,9 @@ async def update_note(
|
||||
body: str,
|
||||
ip: str | None = None,
|
||||
ua: str | None = None,
|
||||
username: str | None = None,
|
||||
) -> dict | None:
|
||||
body = await actions.process_actions(body)
|
||||
title = extract_title(body)
|
||||
tags = extract_tags(body)
|
||||
|
||||
@@ -138,14 +153,31 @@ async def update_note(
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
await write_note_file(row["username"], note_id, body)
|
||||
await db.execute(
|
||||
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(title, note_id),
|
||||
)
|
||||
new_username = username or row["username"]
|
||||
user_id = row["user_id"]
|
||||
rel = row["path"]
|
||||
if new_username != row["username"]:
|
||||
new_user_id = await get_user_id(db, new_username)
|
||||
if new_user_id is None:
|
||||
raise ValueError(f"unknown user: {new_username}")
|
||||
await delete_note_file(row["path"])
|
||||
rel = await write_note_file(new_username, note_id, body)
|
||||
user_id = new_user_id
|
||||
await db.execute(
|
||||
"UPDATE notes SET title = ?, path = ?, user_id = ?,"
|
||||
" updated_at = datetime('now') WHERE id = ?",
|
||||
(title, rel, user_id, note_id),
|
||||
)
|
||||
else:
|
||||
await write_note_file(row["username"], note_id, body)
|
||||
await db.execute(
|
||||
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(title, note_id),
|
||||
)
|
||||
|
||||
await _set_tags(db, note_id, tags)
|
||||
await _index_note(db, note_id, row["user_id"], title, body)
|
||||
await _record_history(db, note_id, row["user_id"], "update", ip, ua)
|
||||
await _index_note(db, note_id, user_id, title, body)
|
||||
await _record_history(db, note_id, user_id, "update", ip, ua)
|
||||
await db.commit()
|
||||
|
||||
return await get_note(note_id)
|
||||
|
||||
Reference in New Issue
Block a user