Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions khata/web/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Attachment handling for notes and trades.

Files live under KHATA_MEDIA_DIR in a date-sharded layout:
<media_dir>/YYYY/MM/DD/<uuid><ext>

The `attachments` table carries the relative path and the owning note_id or
trade_id (XOR). Served back via GET /media/{path:path}.
"""

from __future__ import annotations

import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from typing import BinaryIO

from fastapi import HTTPException

from khata.config import Config

# Conservative allowlist for this PR — notes support images only.
# Video/audio/PDFs land in a later PR.
ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
ALLOWED_IMAGE_MIME = {
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB


def _ext_from_filename(name: str | None) -> str:
if not name:
return ""
return Path(name).suffix.lower()


def save_upload(
cfg: Config,
stream: BinaryIO,
*,
original_filename: str | None,
content_type: str | None,
) -> tuple[Path, str, int, str, str]:
"""Write an upload to media_dir. Returns (abs_path, rel_path, size, mime, kind)."""
ext = _ext_from_filename(original_filename)
if ext not in ALLOWED_IMAGE_EXTS:
raise HTTPException(400, f"Unsupported file type: {ext or '(none)'}")
if content_type and content_type not in ALLOWED_IMAGE_MIME:
raise HTTPException(400, f"Unsupported content type: {content_type}")

now = datetime.now()
rel_dir = Path(f"{now.year:04d}/{now.month:02d}/{now.day:02d}")
abs_dir = cfg.media_dir / rel_dir
abs_dir.mkdir(parents=True, exist_ok=True)

fname = f"{uuid.uuid4().hex}{ext}"
rel_path = rel_dir / fname
abs_path = abs_dir / fname

# Stream-copy with size cap.
total = 0
with open(abs_path, "wb") as out:
while True:
chunk = stream.read(64 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_UPLOAD_BYTES:
out.close()
abs_path.unlink(missing_ok=True)
raise HTTPException(413, f"File exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MB")
out.write(chunk)

return (
abs_path,
str(rel_path).replace("\\", "/"),
total,
content_type or "image/octet-stream",
"image",
)


def record_attachment(
conn: sqlite3.Connection,
*,
user_id: int,
trade_id: int | None,
note_id: int | None,
rel_path: str,
mime: str,
size: int,
kind: str,
caption: str | None = None,
) -> int:
"""Insert an attachments row. Exactly one of trade_id/note_id must be set."""
if (trade_id is None) == (note_id is None):
raise HTTPException(400, "attachment needs exactly one of trade_id or note_id")
cur = conn.execute(
"""
INSERT INTO attachments
(user_id, trade_id, note_id, kind, path, mime, size_bytes, caption)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, trade_id, note_id, kind, rel_path, mime, size, caption),
)
return int(cur.lastrowid)


def attachments_for_note(conn: sqlite3.Connection, user_id: int, note_id: int) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, kind, path, mime, size_bytes, caption, created_at
FROM attachments
WHERE user_id = ? AND note_id = ?
ORDER BY created_at
""",
(user_id, note_id),
).fetchall()


def attachments_for_trade(
conn: sqlite3.Connection, user_id: int, trade_id: int
) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, kind, path, mime, size_bytes, caption, created_at
FROM attachments
WHERE user_id = ? AND trade_id = ?
ORDER BY created_at
""",
(user_id, trade_id),
).fetchall()


def ensure_note_for_date(conn: sqlite3.Connection, user_id: int, iso_date: str) -> int:
row = conn.execute(
"SELECT id FROM notes WHERE user_id = ? AND for_date = ?",
(user_id, iso_date),
).fetchone()
if row:
return int(row["id"])
cur = conn.execute(
"INSERT INTO notes (user_id, for_date, body_md) VALUES (?, ?, '')",
(user_id, iso_date),
)
return int(cur.lastrowid)


def ensure_note_for_trade(conn: sqlite3.Connection, user_id: int, trade_id: int) -> int:
row = conn.execute(
"SELECT id FROM notes WHERE user_id = ? AND trade_id = ?",
(user_id, trade_id),
).fetchone()
if row:
return int(row["id"])
cur = conn.execute(
"INSERT INTO notes (user_id, trade_id, body_md) VALUES (?, ?, '')",
(user_id, trade_id),
)
return int(cur.lastrowid)
89 changes: 87 additions & 2 deletions khata/web/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
from pathlib import Path
from typing import Annotated

from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from khata.config import Config
from khata.core.db import connect, init_schema, user_id_for
from khata.core.money import fmt_rupees, paise_to_rupees
from khata.web import attachments as A
from khata.web import helpers as H
from khata.web import queries as Q
from khata.web.markdown import render as render_markdown

HERE = Path(__file__).parent
TEMPLATES = Jinja2Templates(directory=HERE / "templates")
Expand All @@ -29,6 +31,7 @@
month_name=H.month_name,
fmt_time_ist=H.fmt_time_ist,
today_iso=lambda: H.today_ist().isoformat(),
render_markdown=render_markdown,
)


Expand Down Expand Up @@ -114,6 +117,7 @@ def day_view(
note = Q.get_daily_note(conn, user_id, d)
# `d` counts as an expiry day if any trade in the user's book expires on it.
expiry_days = Q.expiry_days_in_range(conn, user_id, d, H.shift_day(d, 1))
atts = A.attachments_for_note(conn, user_id, note["id"]) if note else []
return TEMPLATES.TemplateResponse(
request,
"day.html",
Expand All @@ -124,7 +128,9 @@ def day_view(
"trades": trades,
"totals": totals,
"note": note,
"attachments": atts,
"endpoint": f"/notes/day/{d.isoformat()}",
"upload_endpoint": f"/upload/note/day/{d.isoformat()}",
"is_expiry": d in expiry_days,
},
)
Expand All @@ -142,16 +148,19 @@ def trade_view(
execs = Q.executions_for_trade(conn, trade_id)
note = Q.get_trade_note(conn, user_id, trade_id)
tags = Q.tags_for_trade(conn, user_id, trade_id)
atts = A.attachments_for_note(conn, user_id, note["id"]) if note else []
return TEMPLATES.TemplateResponse(
request,
"trade.html",
{
"trade": trade,
"executions": execs,
"note": note,
"attachments": atts,
"tags": tags,
"trade_id": trade_id,
"endpoint": f"/notes/trade/{trade_id}",
"upload_endpoint": f"/upload/note/trade/{trade_id}",
},
)

Expand Down Expand Up @@ -212,6 +221,82 @@ def add_trade_tag(
{"tags": tags, "trade_id": trade_id},
)

# ── uploads ────────────────────────────────────────────────────────
@app.post("/upload/note/day/{day}", response_class=JSONResponse)
def upload_to_day_note(
day: str,
file: Annotated[UploadFile, File()],
conn=Depends(_conn),
user_id: int = Depends(_user_id),
):
try:
d = date.fromisoformat(day)
except ValueError as e:
raise HTTPException(400) from e
note_id = A.ensure_note_for_date(conn, user_id, d.isoformat())
abs_path, rel_path, size, mime, kind = A.save_upload(
cfg,
file.file,
original_filename=file.filename,
content_type=file.content_type,
)
A.record_attachment(
conn,
user_id=user_id,
note_id=note_id,
trade_id=None,
rel_path=rel_path,
mime=mime,
size=size,
kind=kind,
caption=file.filename,
)
# EasyMDE's imageUploadFunction expects a plain URL string; we return
# both a url and richer metadata so custom handlers can use the rest.
return {"data": {"filePath": f"/media/{rel_path}"}, "url": f"/media/{rel_path}"}

@app.post("/upload/note/trade/{trade_id}", response_class=JSONResponse)
def upload_to_trade_note(
trade_id: int,
file: Annotated[UploadFile, File()],
conn=Depends(_conn),
user_id: int = Depends(_user_id),
):
if Q.trade_by_id(conn, user_id, trade_id) is None:
raise HTTPException(404)
note_id = A.ensure_note_for_trade(conn, user_id, trade_id)
abs_path, rel_path, size, mime, kind = A.save_upload(
cfg,
file.file,
original_filename=file.filename,
content_type=file.content_type,
)
A.record_attachment(
conn,
user_id=user_id,
note_id=note_id,
trade_id=None,
rel_path=rel_path,
mime=mime,
size=size,
kind=kind,
caption=file.filename,
)
return {"data": {"filePath": f"/media/{rel_path}"}, "url": f"/media/{rel_path}"}

@app.get("/media/{rel_path:path}")
def serve_media(rel_path: str):
# Resolve inside media_dir only — guard against path traversal.
base = cfg.media_dir.resolve()
target = (base / rel_path).resolve()
try:
target.relative_to(base)
except ValueError as e:
raise HTTPException(404) from e
if not target.is_file():
raise HTTPException(404)
return FileResponse(target)

@app.delete("/tags/trade/{trade_id}/{tag_id}", response_class=HTMLResponse)
def delete_trade_tag(
request: Request,
Expand Down
28 changes: 28 additions & 0 deletions khata/web/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Markdown rendering helper.

Uses markdown-it-py with a CommonMark baseline plus a few pragmatic extensions:
- tables
- strikethrough
- fenced code
- auto-linking bare URLs

HTML in the source is escaped — we never inline arbitrary HTML into the DOM.
Attribute safety is handled by markdown-it's default sanitiser config.
"""

from __future__ import annotations

from markdown_it import MarkdownIt

_md = (
MarkdownIt("commonmark", {"html": False, "linkify": True, "breaks": True})
.enable("strikethrough")
.enable("table")
)


def render(body_md: str) -> str:
"""Render a markdown string to HTML. Returns empty string for falsy input."""
if not body_md:
return ""
return _md.render(body_md)
Loading
Loading