cli.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. """Bambuddy administrative CLI.
  2. Invoked via ``python -m backend.app.cli <subcommand>``.
  3. Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk
  4. API key during install (see ``spoolbuddy/install/install.sh``).
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import asyncio
  9. import sys
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import async_sessionmaker
  12. from backend.app.core.auth import generate_api_key
  13. from backend.app.core.database import async_session as default_session_maker, init_db
  14. from backend.app.core.db_dialect import upsert_setting
  15. from backend.app.models.api_key import APIKey
  16. from backend.app.models.settings import Settings
  17. DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
  18. class KioskBootstrapError(RuntimeError):
  19. """Raised when an existing kiosk key would be silently overwritten."""
  20. async def kiosk_bootstrap(
  21. name: str,
  22. *,
  23. force: bool,
  24. session_maker: async_sessionmaker | None = None,
  25. ensure_schema: bool = True,
  26. ) -> str:
  27. """Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
  28. The returned value is the one-time full key string; callers are responsible
  29. for writing it somewhere secure — it cannot be retrieved again.
  30. """
  31. if ensure_schema and session_maker is None:
  32. await init_db()
  33. maker = session_maker or default_session_maker
  34. async with maker() as db:
  35. existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
  36. if existing and not force:
  37. raise KioskBootstrapError(
  38. f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
  39. )
  40. if existing:
  41. await db.delete(existing)
  42. await db.flush()
  43. full_key, key_hash, key_prefix = generate_api_key()
  44. row = APIKey(
  45. name=name,
  46. key_hash=key_hash,
  47. key_prefix=key_prefix,
  48. can_queue=False,
  49. can_control_printer=False,
  50. can_read_status=True,
  51. can_manage_library=False,
  52. # SpoolBuddy kiosk writes NFC scans / scale readings / system
  53. # commands via the /spoolbuddy/* routes — all gated by
  54. # can_manage_inventory now, so the bundled key must opt in.
  55. can_manage_inventory=True,
  56. # Kiosk doesn't need maintenance writes; keep it False so the
  57. # bundled key stays minimally scoped (#1832 follow-up).
  58. can_manage_maintenance=False,
  59. # Kiosk doesn't manage print archives either — keep it minimally
  60. # scoped (#1888).
  61. can_manage_archives=False,
  62. # Kiosk doesn't manage projects — keep it minimally scoped (#1893).
  63. can_manage_projects=False,
  64. printer_ids=None,
  65. enabled=True,
  66. expires_at=None,
  67. )
  68. db.add(row)
  69. # Mark first-run setup as completed so the kiosk URL loads directly
  70. # instead of being force-redirected to /setup by AuthContext. Without
  71. # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy
  72. # first-run wizard (touch-only Pi has no keyboard to complete it).
  73. # Users who want authentication enable it later from the admin UI; the
  74. # API key we just created is already valid so the kiosk keeps working.
  75. await upsert_setting(db, Settings, "setup_completed", "true")
  76. await db.commit()
  77. return full_key
  78. def main(argv: list[str] | None = None) -> int:
  79. parser = argparse.ArgumentParser(
  80. prog="python -m backend.app.cli",
  81. description="Bambuddy administrative commands",
  82. )
  83. sub = parser.add_subparsers(dest="command", required=True)
  84. kiosk = sub.add_parser(
  85. "kiosk-bootstrap",
  86. help="Create an API key for the SpoolBuddy kiosk",
  87. description=(
  88. "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
  89. "kiosk. The full key is printed to stdout — capture it into "
  90. "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
  91. ),
  92. )
  93. kiosk.add_argument(
  94. "--name",
  95. default=DEFAULT_KIOSK_KEY_NAME,
  96. help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
  97. )
  98. kiosk.add_argument(
  99. "--force",
  100. action="store_true",
  101. help="Rotate an existing key with the same name (deletes the old one)",
  102. )
  103. args = parser.parse_args(argv)
  104. if args.command == "kiosk-bootstrap":
  105. try:
  106. key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
  107. except KioskBootstrapError as exc:
  108. print(str(exc), file=sys.stderr)
  109. return 1
  110. print(key)
  111. return 0
  112. return 2
  113. if __name__ == "__main__":
  114. raise SystemExit(main())