diff --git a/__init__.py b/__init__.py index ae9afea..c4c1fc2 100644 --- a/__init__.py +++ b/__init__.py @@ -1,15 +1,14 @@ import asyncio from fastapi import APIRouter -from lnbits.db import Database from loguru import logger +from .crud import db +from .helpers import print_file, setup_upload_folder from .tasks import wait_for_paid_invoices from .views import pay2print_ext_generic from .views_api import pay2print_ext_api -db = Database("ext_pay2print") - scheduled_tasks: list[asyncio.Task] = [] pay2print_ext: APIRouter = APIRouter(prefix="/pay2print", tags=["pay2print"]) @@ -35,5 +34,16 @@ def pay2print_stop(): def pay2print_start(): from lnbits.tasks import create_permanent_unique_task - task = create_permanent_unique_task("ext_testing", wait_for_paid_invoices) + setup_upload_folder() + task = create_permanent_unique_task("ext_pay2print", wait_for_paid_invoices) scheduled_tasks.append(task) + + +__all__ = [ + "db", + "pay2print_ext", + "pay2print_static_files", + "pay2print_start", + "pay2print_stop", + "print_file", +] diff --git a/config.json b/config.json index 5f5db10..73157d6 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "id": "pay2print", "name": "Pay 2 Print", "short_description": "pay sats to print a document", - "tile": "https://raw.githubusercontent.com/lnbits/pay2print/main/static/bitcoin-extension.png", + "tile": "https://raw.githubusercontent.com/lnbits/pay2print/main/static/printer.png", "min_lnbits_version": "1.0.0", "donate": "donate@legend.lnbits.com", "contributors": [ diff --git a/crud.py b/crud.py index 6649808..fba66c8 100644 --- a/crud.py +++ b/crud.py @@ -3,16 +3,16 @@ from lnbits.db import Database -from .models import CreatePrint, CreatePrinter, Print, Printer +from .models import CreatePrinter, Print, Printer db = Database("ext_pay2print") -async def create_print(payment_hash: str, data: CreatePrint) -> Print: +async def create_print(payment_hash: str, printer_id: str, file_name: str) -> Print: _print = Print( payment_hash=payment_hash, - file=data.file, - printer=data.printer, + file=file_name, + printer=printer_id, ) await db.insert("pay2print.print", _print) return _print @@ -37,6 +37,9 @@ async def create_printer(user_id: str, data: CreatePrinter) -> Printer: user_id=user_id, wallet=data.wallet, host=data.host, + amount=data.amount, + width=data.width, + height=data.height, name=data.name or "My Printer", ) await db.insert("pay2print.printer", printer) @@ -44,7 +47,7 @@ async def create_printer(user_id: str, data: CreatePrinter) -> Printer: async def update_printer(printer: Printer) -> Printer: - await db.update("pay2print.print", printer) + await db.update("pay2print.printer", printer) return printer diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..5998ea4 --- /dev/null +++ b/helpers.py @@ -0,0 +1,56 @@ +from os import path +from pathlib import Path, PurePath +from subprocess import PIPE, Popen +from time import time + +from lnbits.settings import settings +from loguru import logger + +upload_dir = Path(settings.lnbits_data_folder, "uploads") + +cmd_check_default_printer = ["lpstat", "-d"] +cmd_print = "lpr" + + +class PrinterError(Exception): + """Error is thrown we we cannot connect to the printer.""" + + +def setup_upload_folder(): + upload_dir.mkdir(parents=True, exist_ok=True) + + +def safe_file_name(file_name: str) -> Path: + # strip leading path from file name to avoid directory traversal attacks + safe_name = PurePath(file_name).name + if path.exists(safe_name): + safe_name = f"{int(time())}_{safe_name}" + return Path(upload_dir, file_name) + + +def print_file_path(file_name: str) -> Path: + return Path(upload_dir, file_name) + + +def check_printer(): + try: + lines = run_command(cmd_check_default_printer) + if len(lines) == 0: + raise PrinterError("No default printer found") + logger.debug(f"Default printer: {lines[0]}") + except Exception as e: + raise PrinterError(f"Error checking default printer: {e}") from e + + +def print_file(file_name: str): + path = print_file_path(file_name) + run_command([cmd_print, str(path)]) + + +def run_command(command: list[str]) -> list[str]: + """Run a command and return the output as list of lines.""" + stdout = Popen(command, stdout=PIPE).stdout + if not stdout: + return [] + out = stdout.read().decode() + return out.splitlines() diff --git a/migrations.py b/migrations.py index 1b502fe..8ccf860 100644 --- a/migrations.py +++ b/migrations.py @@ -10,6 +10,9 @@ async def m001_initial(db: Connection): wallet TEXT NOT NULL, name TEXT NOT NULL, host TEXT NOT NULL, + amount INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); """ diff --git a/models.py b/models.py index 9b2fe07..3629290 100644 --- a/models.py +++ b/models.py @@ -23,12 +23,18 @@ class Printer(BaseModel): wallet: str host: str name: str + amount: int + width: int + height: int created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class CreatePrinter(BaseModel): wallet: str host: str + amount: int + width: int + height: int name: Optional[str] = None @@ -41,8 +47,3 @@ class Print(BaseModel): print_status: PrintStatus = PrintStatus.WAITING updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - - -class CreatePrint(BaseModel): - file: str - printer: str diff --git a/poetry.lock b/poetry.lock index 4c8c53e..ab46071 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1962,6 +1962,17 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + [[package]] name = "pywebpush" version = "1.14.1" @@ -2624,4 +2635,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10 | ^3.9" -content-hash = "3c44aa4c67a390622291459f117b591302ed643bf86e0953c9f3b6c31dc3d504" +content-hash = "a328588e71ff3f76d3f5567857c33bb09d48c5579642951a1a8d12e823ee7283" diff --git a/pyproject.toml b/pyproject.toml index 00053af..c3a9f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ authors = ["Alan Bits "] [tool.poetry.dependencies] python = "^3.10 | ^3.9" lnbits = {version = "*", allow-prereleases = true} +python-multipart = "^0.0.20" [tool.poetry.group.dev.dependencies] black = "^24.3.0" diff --git a/static/js/index.js b/static/js/index.js index 5523ce7..5653ef7 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -3,38 +3,150 @@ window.app = Vue.createApp({ mixins: [windowMixin], data() { return { - pay2printDialog: { + printerUrl: '/pay2print/api/v1/printer', + printUrl: '/pay2print/api/v1/print', + printer: null, + printerLabel: null, + printers: [], + prints: [], + printsTable: { + columns: [ + { + name: 'file', + align: 'left', + label: 'file', + field: 'file' + }, + { + name: 'id', + align: 'left', + label: 'id', + field: 'id' + }, + { + name: 'print_status', + align: 'left', + label: 'print_status', + field: 'print_status' + }, + { + name: 'payment_status', + align: 'left', + label: 'payment_status', + field: 'payment_status' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + printersTable: { + columns: [ + { + name: 'name', + align: 'left', + label: 'name', + field: 'name' + }, + { + name: 'host', + align: 'left', + label: 'host', + field: 'host' + }, + { + name: 'id', + align: 'left', + label: 'id', + field: 'id' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + printerDialog: { show: false, data: {} - }, - pay2printData: [] + } } }, methods: { - submitForm() { + submitPrinterForm() { + const method = this.printerDialog.data.id ? 'PUT' : 'POST' + const url = this.printerDialog.data.id + ? `${this.printerUrl}/${this.printerDialog.data.id}` + : this.printerUrl LNbits.api .request( - this.pay2printDialog.data.id ? 'PUT' : 'POST', - '/pay2print/api/v1/print', + method, + url, this.g.user.wallets[0].adminkey, - this.pay2printDialog.data + this.printerDialog.data ) .then(_ => { - this.getPay2Prints() - this.pay2printDialog.show = false + this.getPrinters() + this.printerDialog.show = false + this.printerDialog.data = {} }) .catch(LNbits.utils.notifyApiError) }, - getPay2Prints() { + openUpdatePrinter(printer_id) { + const printer = this.printers.find(printer => printer.id === printer_id) + this.printerDialog.data = {...printer} + this.printerDialog.show = true + }, + openFile(file_name) { + const print = this.prints.find(print => print.file === file_name) + return `/pay2print/api/v1/file/${print.id}` + }, + getPrints(printer_id) { LNbits.api - .request('GET', '/pay2print/api/v1/print', this.g.user.wallets[0].inkey) + .request( + 'GET', + `${this.printUrl}/${printer_id}`, + this.g.user.wallets[0].inkey + ) .then(response => { - this.pay2printData = response.data + this.prints = response.data }) .catch(LNbits.utils.notifyApiError) + }, + getPrinters() { + LNbits.api + .request('GET', this.printerUrl, this.g.user.wallets[0].inkey) + .then(response => { + this.printers = response.data + if (this.printers.length > 0) { + this.printer = this.printers[0].id + this.printerLabel = this.printers[0].name + } + }) + .catch(LNbits.utils.notifyApiError) + }, + deletePrinter(id) { + LNbits.api + .request( + 'DELETE', + `${this.printerUrl}/${id}`, + this.g.user.wallets[0].adminkey + ) + .then(_ => { + this.getPrinters() + }) + .catch(LNbits.utils.notifyApiError) + } + }, + watch: { + printer(val) { + if (val) { + const printer = this.printers.find(printer => printer.id === val) + this.printerLabel = printer.name + this.getPrints(val) + } } }, created() { - this.getPay2Prints() + this.getPrinters() } }) diff --git a/static/js/public.js b/static/js/public.js new file mode 100644 index 0000000..0ec70c1 --- /dev/null +++ b/static/js/public.js @@ -0,0 +1,18 @@ +window.app = Vue.createApp({ + el: '#vue', + mixins: [window.windowMixin], + data() { + return { + invoice: null, + invoiceAmount: amount + ' sat', + paid: false + } + }, + methods: { + uploaded(e) { + console.log('uploaded', e.xhr) + this.invoice = e.xhr.response + } + }, + created() {} +}) diff --git a/static/printer.png b/static/printer.png new file mode 100644 index 0000000..cdbc201 Binary files /dev/null and b/static/printer.png differ diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index ca7f0d7..0000000 --- a/templates/index.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} {% block page %} - - - - -
- - Cancel -
-
-
-
- - - -
- Extension Development Guide - (also check the - docs) -
-
-
-{% endblock %} diff --git a/templates/pay2print/index.html b/templates/pay2print/index.html new file mode 100644 index 0000000..1c63b9b --- /dev/null +++ b/templates/pay2print/index.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} {% block page %} + + + + + + + + + +
+ + Cancel +
+
+
+
+ + + + New Printer + + + + + +
+
+
Printers
+
+
+ + + + + +
+
+ + + +
+
+
Prints
+ +
+
+ + + + + +
+
+{% endblock %} diff --git a/templates/pay2print/public.html b/templates/pay2print/public.html new file mode 100644 index 0000000..672cb6e --- /dev/null +++ b/templates/pay2print/public.html @@ -0,0 +1,48 @@ +{% extends "public.html" %} {% block toolbar_title %} Printer {{ printer_id }} +{% endblock %} {% block footer %}{% endblock %} {% block page_container %} + + + + +

Upload a pdf file to print

+ +
+ +

Pay this invoice to print!

+
+ + Copy invoice +
+ +

Invoice paid, document is printing!

+ + +
+
+
+
+{% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/views.py b/views.py index 7802e95..0f957a8 100644 --- a/views.py +++ b/views.py @@ -1,9 +1,13 @@ +from http import HTTPStatus + from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse from lnbits.core.models import User from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer +from .crud import get_printer + pay2print_ext_generic = APIRouter(tags=["pay2print"]) @@ -13,5 +17,23 @@ async def index( user: User = Depends(check_user_exists), ): return template_renderer(["pay2print/templates"]).TemplateResponse( - request, "index.html", {"user": user.json()} + request, "pay2print/index.html", {"user": user.json()} + ) + + +@pay2print_ext_generic.get("/public/{printer_id}", response_class=HTMLResponse) +async def public(printer_id: str, request: Request): + printer = await get_printer(printer_id) + if not printer: + return template_renderer().TemplateResponse( + request, "error.html", {"err": "Printer not found"}, HTTPStatus.NOT_FOUND + ) + return template_renderer(["pay2print/templates"]).TemplateResponse( + request, + "pay2print/public.html", + { + "printer_id": printer_id, + "amount": printer.amount, + "printer_name": printer.name, + }, ) diff --git a/views_api.py b/views_api.py index 342f11d..b5324c2 100644 --- a/views_api.py +++ b/views_api.py @@ -1,18 +1,23 @@ from http import HTTPStatus -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import FileResponse from lnbits.core.crud import get_wallet from lnbits.core.models import WalletTypeInfo -from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.core.services import create_invoice +from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key from .crud import ( + create_print, create_printer, delete_printer, + get_print, get_printer, get_printers, get_prints, update_printer, ) +from .helpers import print_file_path, safe_file_name from .models import CreatePrinter, Print, Printer pay2print_ext_api = APIRouter( @@ -46,6 +51,41 @@ async def api_create_printer( return await create_printer(key_info.wallet.user, data) +@pay2print_ext_api.post( + "/upload/{printer_id}", + description="Public upload endpoint, returns a payment request.", +) +async def api_upload_print(printer_id: str, file: UploadFile) -> str: + printer = await get_printer(printer_id) + if not printer: + raise HTTPException(HTTPStatus.NOT_FOUND, "Printer not found.") + if not file or not file.filename or not file.content_type: + raise HTTPException(HTTPStatus.BAD_REQUEST, "No file uploaded.") + if "application/pdf" not in file.content_type: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Only PDF files are supported.") + upload_file_path = safe_file_name(file.filename) + open(upload_file_path, "wb").write(await file.read()) + payment = await create_invoice( + wallet_id=printer.wallet, + amount=100, + memo=f"Print payment for file: {file.filename}", + extra={"tag": "pay2print"}, + ) + await create_print(payment.payment_hash, printer_id, upload_file_path.name) + return payment.bolt11 + + +@pay2print_ext_api.get("/file/{print_id}", dependencies=[Depends(check_user_exists)]) +async def api_show_file(print_id: str) -> FileResponse: + _print = await get_print(print_id) + if not _print: + raise HTTPException(HTTPStatus.NOT_FOUND, "Print not found.") + + return FileResponse( + print_file_path(_print.file), media_type="application/pdf", filename=_print.file + ) + + @pay2print_ext_api.put("/printer/{printer_id}") async def api_update_printer( printer_id: str,