first pass?

This commit is contained in:
2026-09-22 16:11:20 -07:00
parent 46c5621724
commit ce30c6f4f7
23 changed files with 2456 additions and 2 deletions

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
from __future__ import annotations

41
backend/auth.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import ipaddress
from itsdangerous import BadSignature, URLSafeTimedSerializer
from backend.config import settings
MAX_AGE = 60 * 60 * 24 * 30 # 30 days
def _serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(settings.secret_key, salt="snotes-auth")
def is_lan(ip: str | None) -> bool:
if not ip:
return False
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
for cidr in settings.cidr_list:
if addr in ipaddress.ip_network(cidr, strict=False):
return True
return False
def remote_password_ok(password: str) -> bool:
return bool(settings.remote_pass) and password == settings.remote_pass
def issue_auth_cookie() -> str:
return _serializer().dumps("authed")
def check_auth_cookie(token: str) -> bool:
try:
return _serializer().loads(token, max_age=MAX_AGE) == "authed"
except BadSignature:
return False

48
backend/config.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Filesystem mount where notes live (e.g. /mnt/aura/snotes)
data_dir: str = "data/notes"
# SQLite metadata + search index (local disk)
database_path: str = "snotes.db"
# Fixed list of usernames, comma-separated (seeded at startup)
users: str = "schmeeve,ilya"
# Name of the shared space everyone can see
common_space: str = "common"
# Remote-access password (empty = no remote auth)
remote_pass: str = ""
# Secret used to sign session cookies
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"
host: str = "0.0.0.0"
port: int = 8000
log_level: str = "INFO"
@property
def user_list(self) -> list[str]:
return [u.strip() for u in self.users.split(",") if u.strip()]
@property
def cidr_list(self) -> list[str]:
return [c.strip() for c in self.lan_cidrs.split(",") if c.strip()]
settings = Settings()

88
backend/database.py Normal file
View File

@@ -0,0 +1,88 @@
from __future__ import annotations
import aiosqlite
import structlog
from backend.config import settings
logger = structlog.get_logger()
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
title TEXT DEFAULT '',
path TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (note_id, tag_id),
FOREIGN KEY(note_id) REFERENCES notes(id),
FOREIGN KEY(tag_id) REFERENCES tags(id)
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
action TEXT NOT NULL,
at TEXT DEFAULT (datetime('now')),
ip TEXT,
ua TEXT,
FOREIGN KEY(note_id) REFERENCES notes(id),
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
note_id UNINDEXED,
user_id UNINDEXED,
title,
body
);
"""
async def init_db() -> None:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
await db.executescript(SCHEMA)
await db.commit()
await seed_users()
logger.info("database_initialized", path=settings.database_path)
async def seed_users() -> None:
async with aiosqlite.connect(settings.database_path) as db:
for name in settings.user_list:
await db.execute("INSERT OR IGNORE INTO users (name) VALUES (?)", (name,))
await db.commit()
async def get_user_id(db: aiosqlite.Connection, name: str) -> int | None:
row = await db.execute("SELECT id FROM users WHERE name = ?", (name,))
row = await row.fetchone()
return row["id"] if row else None
async def list_users() -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute("SELECT id, name FROM users ORDER BY name")
return [dict(r) for r in await rows.fetchall()]

188
backend/main.py Normal file
View File

@@ -0,0 +1,188 @@
from __future__ import annotations
from contextlib import asynccontextmanager
import structlog
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from backend import auth, database, notes
from backend.config import settings
from backend.models import LoginRequest, NoteCreate, NoteUpdate, UserSelect
logger = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.init_db()
yield
app = FastAPI(title="snotes", lifespan=lifespan)
def client_ip(request: Request) -> str | None:
fwd = request.headers.get("x-forwarded-for")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else None
def client_ua(request: Request) -> str | None:
return request.headers.get("user-agent")
def require_access(request: Request) -> None:
ip = client_ip(request)
if auth.is_lan(ip):
return
token = request.cookies.get("snotes_auth")
if token and auth.check_auth_cookie(token):
return
raise HTTPException(status_code=401, detail="authentication required")
def current_user(request: Request) -> str:
username = request.cookies.get("snotes_user")
return username or settings.user_list[0]
# ---------------------------------------------------------------------------
# SPA + static
# ---------------------------------------------------------------------------
@app.get("/", include_in_schema=False)
async def index() -> FileResponse:
return FileResponse("static/index.html")
@app.get("/favicon.ico", include_in_schema=False)
async def favicon() -> FileResponse:
return FileResponse("static/snotes-main.png")
# ---------------------------------------------------------------------------
# Auth / users
# ---------------------------------------------------------------------------
@app.get("/api/me")
async def me(request: Request) -> dict:
ip = client_ip(request)
return {
"user": current_user(request),
"local": auth.is_lan(ip),
"authed": not auth.is_lan(ip)
and bool(
request.cookies.get("snotes_auth")
and auth.check_auth_cookie(request.cookies["snotes_auth"])
),
}
@app.get("/api/users")
async def users(_: None = Depends(require_access)) -> list[dict]:
return await database.list_users()
@app.post("/api/users/select")
async def select_user(body: UserSelect, _: None = Depends(require_access)) -> Response:
if body.username not in settings.user_list and body.username != settings.common_space:
raise HTTPException(status_code=404, detail="unknown user")
resp = JSONResponse({"username": body.username})
resp.set_cookie("snotes_user", body.username, httponly=True, samesite="lax")
return resp
@app.post("/api/auth/login")
async def login(body: LoginRequest) -> Response:
if not auth.remote_password_ok(body.password):
raise HTTPException(status_code=401, detail="invalid password")
resp = JSONResponse({"ok": True})
resp.set_cookie("snotes_auth", auth.issue_auth_cookie(), httponly=True, samesite="lax")
return resp
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
@app.post("/api/notes")
async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)):
username = current_user(request)
if not body.body.strip():
raise HTTPException(status_code=400, detail="empty note")
try:
note = await notes.create_note(
username, body.body, ip=client_ip(request), ua=client_ua(request)
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return note
@app.get("/api/notes")
async def list_notes(request: Request, _: None = Depends(require_access)):
username = current_user(request)
return await notes.list_notes(username)
@app.get("/api/notes/recent")
async def recent(_: None = Depends(require_access)):
return await notes.list_recent()
@app.get("/api/notes/{note_id}")
async def get_note(note_id: str, _: None = Depends(require_access)):
note = await notes.get_note(note_id)
if note is None:
raise HTTPException(status_code=404, detail="note not found")
return note
@app.put("/api/notes/{note_id}")
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))
if note is None:
raise HTTPException(status_code=404, detail="note not found")
return note
@app.delete("/api/notes/{note_id}")
async def delete_note(note_id: str, request: Request, _: None = Depends(require_access)):
ok = await notes.delete_note(note_id, ip=client_ip(request), ua=client_ua(request))
if not ok:
raise HTTPException(status_code=404, detail="note not found")
return {"ok": True}
@app.get("/api/notes/{note_id}/history")
async def note_history(note_id: str, _: None = Depends(require_access)):
return await notes.get_history(note_id)
# ---------------------------------------------------------------------------
# Tags / search
# ---------------------------------------------------------------------------
@app.get("/api/tags")
async def tags(_: None = Depends(require_access)):
return await notes.tag_counts()
@app.get("/api/search")
async def search(q: str, _: None = Depends(require_access)):
if not q.strip():
return []
return await notes.search_notes(q)
# Mount static assets last so /api routes take precedence
app.mount("/static", StaticFiles(directory="static"), name="static")

19
backend/models.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from pydantic import BaseModel
class UserSelect(BaseModel):
username: str
class NoteCreate(BaseModel):
body: str
class NoteUpdate(BaseModel):
body: str
class LoginRequest(BaseModel):
password: str

326
backend/notes.py Normal file
View File

@@ -0,0 +1,326 @@
from __future__ import annotations
import re
import uuid
import aiosqlite
from backend.config import settings
from backend.database import get_user_id
TAG_RE = re.compile(r"#[A-Za-z0-9_-]+")
def extract_tags(text: str) -> list[str]:
seen: list[str] = []
for match in TAG_RE.finditer(text):
tag = match.group(0)[1:].lower()
if tag not in seen:
seen.append(tag)
return seen
def extract_title(text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("# "):
title = stripped[2:].strip()
else:
title = stripped
if title:
return title[:200]
return "Untitled"
def note_path(username: str, note_id: str) -> str:
return f"{username}/{note_id}.md"
async def ensure_user_dir(username: str) -> None:
import os
os.makedirs(os.path.join(settings.data_dir, username), exist_ok=True)
async def write_note_file(username: str, note_id: str, body: str) -> str:
from pathlib import Path
await ensure_user_dir(username)
rel = note_path(username, note_id)
full = Path(settings.data_dir) / rel
full.write_text(body, encoding="utf-8")
return rel
async def read_note_file(rel_path: str) -> str:
from pathlib import Path
full = Path(settings.data_dir) / rel_path
if not full.is_file():
raise FileNotFoundError(rel_path)
return full.read_text(encoding="utf-8")
async def delete_note_file(rel_path: str) -> None:
from pathlib import Path
full = Path(settings.data_dir) / rel_path
if full.is_file():
full.unlink()
def new_note_id() -> str:
return uuid.uuid4().hex
# ---------------------------------------------------------------------------
# Note CRUD (metadata + tags + FTS)
# ---------------------------------------------------------------------------
async def create_note(
username: str,
body: str,
ip: str | None = None,
ua: str | None = None,
) -> dict:
title = extract_title(body)
tags = extract_tags(body)
note_id = new_note_id()
rel = await write_note_file(username, note_id, body)
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
user_id = await get_user_id(db, username)
if user_id is None:
raise ValueError(f"unknown user: {username}")
await db.execute(
"INSERT INTO notes (id, user_id, title, path) VALUES (?, ?, ?, ?)",
(note_id, user_id, title, rel),
)
await _set_tags(db, note_id, tags)
await _index_note(db, note_id, user_id, title, body)
await _record_history(db, note_id, user_id, "create", ip, ua)
await db.commit()
row = await db.execute(
"SELECT n.id, n.title, n.path, n.created_at, n.updated_at,"
" u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
(note_id,),
)
note = dict(await row.fetchone())
note["tags"] = tags
note["body"] = body
return note
async def update_note(
note_id: str,
body: str,
ip: str | None = None,
ua: str | None = None,
) -> dict | None:
title = extract_title(body)
tags = extract_tags(body)
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT n.id, n.user_id, n.path, u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
(note_id,),
)
row = await row.fetchone()
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),
)
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 db.commit()
return await get_note(note_id)
async def get_note(note_id: str) -> dict | None:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT n.id, n.title, n.path, n.created_at, n.updated_at,"
" u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id"
" WHERE n.id = ? AND n.deleted_at IS NULL",
(note_id,),
)
row = await row.fetchone()
if row is None:
return None
note = dict(row)
note["tags"] = await get_note_tags(note_id)
note["body"] = await read_note_file(note["path"])
return note
async def delete_note(
note_id: str,
ip: str | None = None,
ua: str | None = None,
) -> bool:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
row = await db.execute(
"SELECT user_id, path FROM notes WHERE id = ? AND deleted_at IS NULL",
(note_id,),
)
row = await row.fetchone()
if row is None:
return False
await delete_note_file(row["path"])
await db.execute(
"UPDATE notes SET deleted_at = datetime('now') WHERE id = ?",
(note_id,),
)
await _record_history(db, note_id, row["user_id"], "delete", ip, ua)
await db.commit()
return True
async def list_notes(username: str, limit: int = 100) -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id"
" WHERE n.deleted_at IS NULL AND u.name = ?"
" ORDER BY n.updated_at DESC LIMIT ?",
(username, limit),
)
notes = [dict(r) for r in await rows.fetchall()]
for note in notes:
note["tags"] = await get_note_tags(note["id"])
return notes
async def list_recent(limit: int = 30) -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username"
" FROM notes n JOIN users u ON u.id = n.user_id"
" WHERE n.deleted_at IS NULL"
" ORDER BY n.updated_at DESC LIMIT ?",
(limit,),
)
notes = [dict(r) for r in await rows.fetchall()]
for note in notes:
note["tags"] = await get_note_tags(note["id"])
return notes
async def get_note_tags(note_id: str) -> list[str]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT t.name FROM tags t JOIN note_tags nt ON nt.tag_id = t.id"
" WHERE nt.note_id = ? ORDER BY t.name",
(note_id,),
)
return [r["name"] for r in await rows.fetchall()]
async def tag_counts() -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT t.name, COUNT(nt.note_id) AS count"
" FROM tags t JOIN note_tags nt ON nt.tag_id = t.id"
" JOIN notes n ON n.id = nt.note_id AND n.deleted_at IS NULL"
" GROUP BY t.id ORDER BY count DESC, t.name LIMIT 50"
)
return [dict(r) for r in await rows.fetchall()]
async def search_notes(query: str, limit: int = 50) -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT f.note_id AS id, f.title, f.body, n.updated_at,"
" n.created_at, u.name AS username, bm25(notes_fts) AS rank"
" FROM notes_fts f"
" JOIN notes n ON n.id = f.note_id AND n.deleted_at IS NULL"
" JOIN users u ON u.id = n.user_id"
" WHERE notes_fts MATCH ? ORDER BY rank LIMIT ?",
(query, limit),
)
notes = [dict(r) for r in await rows.fetchall()]
for note in notes:
note["tags"] = await get_note_tags(note["id"])
note.pop("body", None)
return notes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _set_tags(db: aiosqlite.Connection, note_id: str, tags: list[str]) -> None:
await db.execute("DELETE FROM note_tags WHERE note_id = ?", (note_id,))
for tag in tags:
await db.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
row = await db.execute("SELECT id FROM tags WHERE name = ?", (tag,))
row = await row.fetchone()
await db.execute(
"INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)",
(note_id, row["id"]),
)
async def _index_note(
db: aiosqlite.Connection,
note_id: str,
user_id: int,
title: str,
body: str,
) -> None:
await db.execute("DELETE FROM notes_fts WHERE note_id = ?", (note_id,))
await db.execute(
"INSERT INTO notes_fts (note_id, user_id, title, body) VALUES (?, ?, ?, ?)",
(note_id, user_id, title, body),
)
async def _record_history(
db: aiosqlite.Connection,
note_id: str,
user_id: int,
action: str,
ip: str | None,
ua: str | None,
) -> None:
await db.execute(
"INSERT INTO history (note_id, user_id, action, ip, ua) VALUES (?, ?, ?, ?, ?)",
(note_id, user_id, action, ip, ua),
)
async def get_history(note_id: str) -> list[dict]:
async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row
rows = await db.execute(
"SELECT h.id, h.action, h.at, h.ip, h.ua, u.name AS username"
" FROM history h JOIN users u ON u.id = h.user_id"
" WHERE h.note_id = ? ORDER BY h.at DESC",
(note_id,),
)
return [dict(r) for r in await rows.fetchall()]