external_links.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """API routes for external sidebar links."""
  2. import logging
  3. import uuid
  4. from datetime import timedelta
  5. from pathlib import Path
  6. from typing import cast
  7. from urllib.parse import urlsplit
  8. from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
  9. from fastapi.responses import FileResponse
  10. from sqlalchemy import select, update
  11. from sqlalchemy.engine import CursorResult
  12. from sqlalchemy.exc import IntegrityError
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.core.auth import (
  15. RequirePermissionIfAuthEnabled,
  16. ScopedCaller,
  17. require_media_token_permission,
  18. require_notification_send,
  19. )
  20. from backend.app.core.config import settings as app_settings
  21. from backend.app.core.database import get_db
  22. from backend.app.core.permissions import Permission
  23. from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
  24. from backend.app.models.user import User
  25. from backend.app.schemas.external_link import (
  26. ExternalLinkBadgeSet,
  27. ExternalLinkCreate,
  28. ExternalLinkReorder,
  29. ExternalLinkResponse,
  30. ExternalLinkUpdate,
  31. )
  32. from backend.app.utils.local_time import utcnow_naive
  33. # Directory for storing custom icons
  34. ICONS_DIR = app_settings.base_dir / "icons"
  35. ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
  36. logger = logging.getLogger(__name__)
  37. router = APIRouter(prefix="/external-links", tags=["external-links"])
  38. # An app that stopped reporting (stopped, crashed) leaves no old number behind.
  39. BADGE_FRESH_FOR = timedelta(minutes=10)
  40. _DEFAULT_PORTS = {"http": 80, "https": 443}
  41. def _same_place(url: str) -> tuple[str, str, int | None, str]:
  42. """Scheme, host, port and path of an address. A login in it, the default
  43. port written out and a trailing slash don't count."""
  44. parts = urlsplit(url.strip())
  45. scheme = parts.scheme.lower()
  46. try:
  47. port = parts.port or _DEFAULT_PORTS.get(scheme)
  48. except ValueError:
  49. port = None
  50. return scheme, (parts.hostname or "").lower(), port, parts.path.rstrip("/")
  51. @router.get("/", response_model=list[ExternalLinkResponse])
  52. async def list_external_links(
  53. db: AsyncSession = Depends(get_db),
  54. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
  55. ):
  56. """List all external links ordered by sort_order."""
  57. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
  58. links = result.scalars().all()
  59. fresh_since = utcnow_naive() - BADGE_FRESH_FOR
  60. badges = {
  61. b.link_id: b.count
  62. for b in (await db.execute(select(ExternalLinkBadge).where(ExternalLinkBadge.updated_at >= fresh_since)))
  63. .scalars()
  64. .all()
  65. }
  66. return [
  67. ExternalLinkResponse.model_validate(link).model_copy(update={"badge_count": badges.get(link.id)})
  68. for link in links
  69. ]
  70. @router.put("/badge")
  71. async def set_badge(
  72. data: ExternalLinkBadgeSet,
  73. db: AsyncSession = Depends(get_db),
  74. _: ScopedCaller = Depends(require_notification_send()),
  75. ):
  76. """Set the count on the sidebar links pointing at an app, e.g. Bambuddy
  77. Orders' To do. For the app itself: an API key needs the "Send
  78. notifications" permission, like for its messages. The count shows while
  79. the app keeps reporting it."""
  80. wanted = _same_place(data.url)
  81. links = [
  82. link for link in (await db.execute(select(ExternalLink))).scalars().all() if _same_place(link.url) == wanted
  83. ]
  84. if not links:
  85. raise HTTPException(status_code=404, detail="No sidebar link points at this address")
  86. now = utcnow_naive()
  87. for link in links:
  88. values = {"count": data.count, "updated_at": now}
  89. changed = cast(
  90. CursorResult,
  91. await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values)),
  92. )
  93. if changed.rowcount:
  94. continue
  95. try:
  96. async with db.begin_nested():
  97. db.add(ExternalLinkBadge(link_id=link.id, **values))
  98. except IntegrityError:
  99. # Another report made the row in the meantime: update that one.
  100. await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values))
  101. await db.commit()
  102. return {"links": len(links)}
  103. @router.post("/", response_model=ExternalLinkResponse)
  104. async def create_external_link(
  105. link_data: ExternalLinkCreate,
  106. db: AsyncSession = Depends(get_db),
  107. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
  108. ):
  109. """Create a new external link."""
  110. # Get the highest sort_order to place new link at end
  111. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1))
  112. last_link = result.scalar_one_or_none()
  113. next_order = (last_link.sort_order + 1) if last_link else 0
  114. link = ExternalLink(
  115. name=link_data.name,
  116. url=link_data.url,
  117. icon=link_data.icon,
  118. sort_order=next_order,
  119. )
  120. db.add(link)
  121. await db.commit()
  122. await db.refresh(link)
  123. logger.info("Created external link: %s -> %s", link.name, link.url)
  124. return link
  125. @router.get("/{link_id}", response_model=ExternalLinkResponse)
  126. async def get_external_link(
  127. link_id: int,
  128. db: AsyncSession = Depends(get_db),
  129. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
  130. ):
  131. """Get a specific external link."""
  132. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  133. link = result.scalar_one_or_none()
  134. if not link:
  135. raise HTTPException(status_code=404, detail="External link not found")
  136. return link
  137. @router.patch("/{link_id}", response_model=ExternalLinkResponse)
  138. async def update_external_link(
  139. link_id: int,
  140. update_data: ExternalLinkUpdate,
  141. db: AsyncSession = Depends(get_db),
  142. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  143. ):
  144. """Update an external link."""
  145. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  146. link = result.scalar_one_or_none()
  147. if not link:
  148. raise HTTPException(status_code=404, detail="External link not found")
  149. # Update only provided fields
  150. update_dict = update_data.model_dump(exclude_unset=True)
  151. for key, value in update_dict.items():
  152. setattr(link, key, value)
  153. await db.commit()
  154. await db.refresh(link)
  155. logger.info("Updated external link: %s", link.name)
  156. return link
  157. @router.delete("/{link_id}")
  158. async def delete_external_link(
  159. link_id: int,
  160. db: AsyncSession = Depends(get_db),
  161. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
  162. ):
  163. """Delete an external link."""
  164. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  165. link = result.scalar_one_or_none()
  166. if not link:
  167. raise HTTPException(status_code=404, detail="External link not found")
  168. name = link.name
  169. await db.delete(link)
  170. await db.commit()
  171. logger.info("Deleted external link: %s", name)
  172. return {"message": f"External link '{name}' deleted"}
  173. @router.put("/reorder", response_model=list[ExternalLinkResponse])
  174. async def reorder_external_links(
  175. reorder_data: ExternalLinkReorder,
  176. db: AsyncSession = Depends(get_db),
  177. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  178. ):
  179. """Update the sort order of external links."""
  180. # Update sort_order for each link based on position in the list
  181. for index, link_id in enumerate(reorder_data.ids):
  182. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  183. link = result.scalar_one_or_none()
  184. if link:
  185. link.sort_order = index
  186. await db.commit()
  187. # Return updated list
  188. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
  189. links = result.scalars().all()
  190. logger.info("Reordered %s external links", len(reorder_data.ids))
  191. return links
  192. @router.post("/{link_id}/icon", response_model=ExternalLinkResponse)
  193. async def upload_icon(
  194. link_id: int,
  195. file: UploadFile = File(...),
  196. db: AsyncSession = Depends(get_db),
  197. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  198. ):
  199. """Upload a custom icon for an external link."""
  200. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  201. link = result.scalar_one_or_none()
  202. if not link:
  203. raise HTTPException(status_code=404, detail="External link not found")
  204. # Validate file extension
  205. if not file.filename:
  206. raise HTTPException(status_code=400, detail="No filename provided")
  207. ext = Path(file.filename).suffix.lower()
  208. if ext not in ALLOWED_EXTENSIONS:
  209. raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}")
  210. # Create icons directory if it doesn't exist
  211. ICONS_DIR.mkdir(parents=True, exist_ok=True)
  212. # Delete old custom icon if exists
  213. if link.custom_icon:
  214. old_path = ICONS_DIR / link.custom_icon
  215. if old_path.exists():
  216. old_path.unlink()
  217. # Generate unique filename
  218. filename = f"{uuid.uuid4().hex}{ext}"
  219. filepath = ICONS_DIR / filename
  220. # Save file
  221. content = await file.read()
  222. with open(filepath, "wb") as f:
  223. f.write(content)
  224. # Update link
  225. link.custom_icon = filename
  226. await db.commit()
  227. await db.refresh(link)
  228. logger.info("Uploaded custom icon for link %s: %s", link.name, filename)
  229. return link
  230. @router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
  231. async def delete_icon(
  232. link_id: int,
  233. db: AsyncSession = Depends(get_db),
  234. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  235. ):
  236. """Delete the custom icon for an external link."""
  237. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  238. link = result.scalar_one_or_none()
  239. if not link:
  240. raise HTTPException(status_code=404, detail="External link not found")
  241. if link.custom_icon:
  242. filepath = ICONS_DIR / link.custom_icon
  243. if filepath.exists():
  244. filepath.unlink()
  245. link.custom_icon = None
  246. await db.commit()
  247. await db.refresh(link)
  248. logger.info("Deleted custom icon for link %s", link.name)
  249. return link
  250. @router.get("/{link_id}/icon")
  251. async def get_icon(
  252. link_id: int,
  253. db: AsyncSession = Depends(get_db),
  254. _: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
  255. ):
  256. """Get the custom icon for an external link.
  257. Requires a media token query param (?token=xxx) when auth is enabled, and
  258. the same ``external_links:read`` every other read on this router takes.
  259. Previously it took the camera-stream token, so a sidebar icon was visible
  260. only to users who could also watch the printer camera (#3025).
  261. """
  262. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  263. link = result.scalar_one_or_none()
  264. if not link:
  265. raise HTTPException(status_code=404, detail="External link not found")
  266. if not link.custom_icon:
  267. raise HTTPException(status_code=404, detail="No custom icon set")
  268. filepath = ICONS_DIR / link.custom_icon
  269. if not filepath.exists():
  270. raise HTTPException(status_code=404, detail="Icon file not found")
  271. return FileResponse(filepath)