snotes updates, actions, attachments, easy user space

This commit is contained in:
2026-09-22 17:31:55 -07:00
parent ce30c6f4f7
commit abfbf3de1f
21 changed files with 669 additions and 53 deletions

75
backend/actions.py Normal file
View 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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)