diff --git a/lnbits/extensions/tipjar/README.md b/lnbits/extensions/tipjar/README.md new file mode 100644 index 00000000..4965ec93 --- /dev/null +++ b/lnbits/extensions/tipjar/README.md @@ -0,0 +1,15 @@ +

Tip Jars

+

Accept tips in Bitcoin, with small messages attached!

+The TipJar extension allows you to integrate Bitcoin Lightning (and on-chain) tips into your website or social media! + +![image](https://user-images.githubusercontent.com/28876473/134997129-c2f3f13c-a65d-42ed-a9c4-8a1da569d74f.png) + +

How to set it up

+ +1. Simply create a new Tip Jar with the desired details (onchain optional): +![image](https://user-images.githubusercontent.com/28876473/134996842-ec2f2783-2eef-4671-8eaf-023713865512.png) +1. Share the URL you get from this little button: +![image](https://user-images.githubusercontent.com/28876473/134996973-f8ed4632-ea2f-4b62-83f1-1e4c6b6c91fa.png) + + +

And that's it already! Let the sats flow!

diff --git a/lnbits/extensions/tipjar/__init__.py b/lnbits/extensions/tipjar/__init__.py new file mode 100644 index 00000000..7877cab3 --- /dev/null +++ b/lnbits/extensions/tipjar/__init__.py @@ -0,0 +1,19 @@ +import asyncio +from fastapi import APIRouter, FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.routing import Mount +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_tipjar") + +tipjar_ext: APIRouter = APIRouter(prefix="/tipjar", tags=["tipjar"]) + + +def tipjar_renderer(): + return template_renderer(["lnbits/extensions/tipjar/templates"]) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/tipjar/config.json b/lnbits/extensions/tipjar/config.json new file mode 100644 index 00000000..e48eb4ea --- /dev/null +++ b/lnbits/extensions/tipjar/config.json @@ -0,0 +1,6 @@ +{ + "name": "Tip Jar", + "short_description": "Accept Bitcoin donations, with messages attached!", + "icon": "favorite", + "contributors": ["Fittiboy"] +} diff --git a/lnbits/extensions/tipjar/crud.py b/lnbits/extensions/tipjar/crud.py new file mode 100644 index 00000000..1d30fe23 --- /dev/null +++ b/lnbits/extensions/tipjar/crud.py @@ -0,0 +1,123 @@ +from . import db +from .models import Tip, TipJar, createTip, createTipJar + +from ..satspay.crud import delete_charge # type: ignore + +from typing import Optional + +from lnbits.db import SQLITE + + +async def create_tip(data: createTip) -> Tip: + """Create a new Tip""" + await db.execute( + """ + INSERT INTO tipjar.Tips ( + id, + wallet, + name, + message, + sats, + tipjar + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (data.id, data.wallet, data.name, data.message, data.sats, data.tipjar), + ) + + tip = await get_tip(data.id) + assert tip, "Newly created tip couldn't be retrieved" + return tip + + +async def create_tipjar(data: createTipJar) -> TipJar: + """Create a new TipJar""" + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO tipjar.TipJars ( + name, + wallet, + webhook, + onchain + ) + VALUES (?, ?, ?, ?) + {returning} + """, + (data.name, data.wallet, data.webhook, data.onchain), + ) + if db.type == SQLITE: + tipjar_id = result._result_proxy.lastrowid + else: + tipjar_id = result[0] + + tipjar = await get_tipjar(tipjar_id) + assert tipjar + return tipjar + + +async def get_tipjar(tipjar_id: int) -> Optional[TipJar]: + """Return a tipjar by ID""" + row = await db.fetchone("SELECT * FROM tipjar.TipJars WHERE id = ?", (tipjar_id,)) + return TipJar.from_row(row) if row else None + + +async def get_tipjars(wallet_id: str) -> Optional[list]: + """Return all TipJars belonging assigned to the wallet_id""" + rows = await db.fetchall( + "SELECT * FROM tipjar.TipJars WHERE wallet = ?", (wallet_id,) + ) + return [TipJar.from_row(row) for row in rows] if rows else None + + +async def delete_tipjar(tipjar_id: int) -> None: + """Delete a TipJar and all corresponding Tips""" + await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,)) + rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,)) + for row in rows: + await delete_tip(row["id"]) + + +async def get_tip(tip_id: str) -> Optional[Tip]: + """Return a Tip""" + row = await db.fetchone("SELECT * FROM tipjar.Tips WHERE id = ?", (tip_id,)) + return Tip.from_row(row) if row else None + + +async def get_tips(wallet_id: str) -> Optional[list]: + """Return all Tips assigned to wallet_id""" + rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE wallet = ?", (wallet_id,)) + return [Tip.from_row(row) for row in rows] if rows else None + + +async def delete_tip(tip_id: str) -> None: + """Delete a Tip and its corresponding statspay charge""" + await db.execute("DELETE FROM tipjar.Tips WHERE id = ?", (tip_id,)) + await delete_charge(tip_id) + + +async def update_tip(tip_id: str, **kwargs) -> Tip: + """Update a Tip""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE tipjar.Tips SET {q} WHERE id = ?", + (*kwargs.values(), tip_id), + ) + row = await db.fetchone("SELECT * FROM tipjar.Tips WHERE id = ?", (tip_id,)) + assert row, "Newly updated tip couldn't be retrieved" + return Tip(**row) + + +async def update_tipjar(tipjar_id: str, **kwargs) -> TipJar: + """Update a tipjar""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE tipjar.TipJars SET {q} WHERE id = ?", + (*kwargs.values(), tipjar_id), + ) + row = await db.fetchone("SELECT * FROM tipjar.TipJars WHERE id = ?", (tipjar_id,)) + assert row, "Newly updated tipjar couldn't be retrieved" + return TipJar(**row) diff --git a/lnbits/extensions/tipjar/helpers.py b/lnbits/extensions/tipjar/helpers.py new file mode 100644 index 00000000..7e89b7a9 --- /dev/null +++ b/lnbits/extensions/tipjar/helpers.py @@ -0,0 +1,19 @@ +from lnbits.core.crud import get_wallet +from .crud import get_tipjar + + +async def get_charge_details(tipjar_id): + """Return the default details for a satspay charge""" + tipjar = await get_tipjar(tipjar_id) + wallet_id = tipjar.wallet + wallet = await get_wallet(wallet_id) + user = wallet.user + details = { + "time": 1440, + "user": user, + "lnbitswallet": wallet_id, + "onchainwallet": tipjar.onchain, + "completelink": "/tipjar/" + str(tipjar_id), + "completelinktext": "Thanks for the tip!" + } + return details diff --git a/lnbits/extensions/tipjar/migrations.py b/lnbits/extensions/tipjar/migrations.py new file mode 100644 index 00000000..6b58fbca --- /dev/null +++ b/lnbits/extensions/tipjar/migrations.py @@ -0,0 +1,27 @@ +async def m001_initial(db): + + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS tipjar.TipJars ( + id {db.serial_primary_key}, + name TEXT NOT NULL, + wallet TEXT NOT NULL, + onchain TEXT, + webhook TEXT + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS tipjar.Tips ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + message TEXT NOT NULL, + sats INT NOT NULL, + tipjar INT NOT NULL, + FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id) + ); + """ + ) diff --git a/lnbits/extensions/tipjar/models.py b/lnbits/extensions/tipjar/models.py new file mode 100644 index 00000000..3e68f846 --- /dev/null +++ b/lnbits/extensions/tipjar/models.py @@ -0,0 +1,64 @@ +import json +from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult +from lnurl.types import LnurlPayMetadata # type: ignore +from sqlite3 import Row +from typing import NamedTuple, Optional, Dict +import shortuuid # type: ignore +from fastapi.param_functions import Query +from pydantic.main import BaseModel +from pydantic import BaseModel +from typing import Optional, NamedTuple +from fastapi import FastAPI, Request + + +class createTip(BaseModel): + id: str + wallet: str + sats: int + tipjar: int + name: str = "Anonymous" + message: str = "" + + +class Tip(NamedTuple): + """A Tip represents a single donation""" + + id: str # This ID always corresponds to a satspay charge ID + wallet: str + name: str # Name of the donor + message: str # Donation message + sats: int + tipjar: int # The ID of the corresponding tip jar + + @classmethod + def from_row(cls, row: Row) -> "Tip": + return cls(**dict(row)) + + +class createTipJar(BaseModel): + name: str + wallet: str + webhook: str = None + onchain: str = None + + +class createTips(BaseModel): + name: str + sats: str + tipjar: str + message: str + + +class TipJar(NamedTuple): + """A TipJar represents a user's tip jar""" + + id: int + name: str # The name of the donatee + wallet: str # Lightning wallet + onchain: Optional[str] # Watchonly wallet + webhook: Optional[str] # URL to POST tips to + + @classmethod + def from_row(cls, row: Row) -> "TipJar": + return cls(**dict(row)) diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html new file mode 100644 index 00000000..42788bad --- /dev/null +++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html @@ -0,0 +1,16 @@ + + +

+ Tip Jar: Receive tips with messages! +

+

+ Your personal Bitcoin tip page, which supports + lightning and on-chain payments. + Notifications, including a donation message, + can be sent via webhook. + + Created by, Fitti +

+
+
diff --git a/lnbits/extensions/tipjar/templates/tipjar/display.html b/lnbits/extensions/tipjar/templates/tipjar/display.html new file mode 100644 index 00000000..87bce0f4 --- /dev/null +++ b/lnbits/extensions/tipjar/templates/tipjar/display.html @@ -0,0 +1,92 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
Tip {{ donatee }} some sats!
+
+ + + + +
+ Submit +
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html new file mode 100644 index 00000000..7c58a74f --- /dev/null +++ b/lnbits/extensions/tipjar/templates/tipjar/index.html @@ -0,0 +1,447 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New TipJar + + + + + +
+
+
TipJars
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Tips
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} TipJar extension +
+
+ + + {% include "tipjar/_api_docs.html" %} + +
+
+ + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+ + +
+ Update TipJar + + Create TipJar + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/tipjar/views.py b/lnbits/extensions/tipjar/views.py new file mode 100644 index 00000000..03e93993 --- /dev/null +++ b/lnbits/extensions/tipjar/views.py @@ -0,0 +1,47 @@ +from .crud import get_tipjar + +from http import HTTPStatus +import httpx +from collections import defaultdict +from lnbits.decorators import check_user_exists + +from functools import wraps +import hashlib +from lnbits.core.services import check_invoice_status +from lnbits.core.crud import update_payment_status, get_standalone_payment +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from fastapi.params import Depends +from fastapi.param_functions import Query +import random + +from datetime import datetime +from http import HTTPStatus +from . import tipjar_ext, tipjar_renderer +from lnbits.core.models import User, Payment + +templates = Jinja2Templates(directory="templates") + + +@tipjar_ext.get("/") +async def index(request: Request, user: User = Depends(check_user_exists)): + return tipjar_renderer().TemplateResponse( + "tipjar/index.html", {"request": request, "user": user.dict()} + ) + + +@tipjar_ext.route("/{id}") +async def tip(request: Request, id: str = Query(None)): + """Return the donation form for the Tipjar corresponding to id""" + tipjar = await get_tipjar(id) + if not tipjar: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TipJar does not exist." + ) + + return tipjar_renderer().TemplateResponse( + "tipjar/display.html", + {"request": request, "donatee": tipjar.name, "tipjar": tipjar.id}, + ) diff --git a/lnbits/extensions/tipjar/views_api.py b/lnbits/extensions/tipjar/views_api.py new file mode 100644 index 00000000..1c409db0 --- /dev/null +++ b/lnbits/extensions/tipjar/views_api.py @@ -0,0 +1,193 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore +from starlette.exceptions import HTTPException + +from lnbits.decorators import ( + WalletTypeInfo, + get_key_type, +) +from lnbits.core.crud import get_user + +from . import tipjar_ext +from .helpers import get_charge_details +from .crud import ( + create_tipjar, + get_tipjar, + create_tip, + get_tipjars, + get_tip, + get_tips, + update_tip, + update_tipjar, + delete_tip, + delete_tipjar, +) +from ..satspay.crud import create_charge +from .models import createTipJar, createTips, createTip + + +@tipjar_ext.post("/api/v1/tipjars") +async def api_create_tipjar(data: createTipJar): + """Create a tipjar, which holds data about how/where to post tips""" + try: + tipjar = await create_tipjar(**data) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + return tipjar.dict() + + +@tipjar_ext.post("/api/v1/tips") +async def api_create_tip(data: createTips, dataCreateTip: createTip): + """Take data from tip form and return satspay charge""" + sats = data.sats + message = data.get("message", "")[:144] + if not message: + message = "No message" + tipjar_id = data.tipjar + tipjar = await get_tipjar(tipjar_id) + webhook = tipjar.webhook + charge_details = await get_charge_details(tipjar.id) + name = data.get("name", "")[:25] + # Ensure that description string can be split reliably + name = name.replace('"', "''") + if not name: + name = "Anonymous" + description = f'"{name}": {message}' + + charge = await create_charge( + amount=sats, + webhook=webhook, + description=description, + **charge_details, + ) + dataCreateTip.id = charge.id + dataCreateTip.wallet = tipjar.wallet + dataCreateTip.message = message + dataCreateTip.name = name + dataCreateTip.sats = data.sats + dataCreateTip.tipjar = data.tipjar + await create_tip(dataCreateTip) + + return {"redirect_url": f"/satspay/{charge.id}"} + + +@tipjar_ext.get("/api/v1/tipjars") +async def api_get_tipjars(wallet: WalletTypeInfo = Depends(get_key_type)): + """Return list of all tipjars assigned to wallet with given invoice key""" + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + tipjars = [] + for wallet_id in wallet_ids: + new_tipjars = await get_tipjars(wallet_id) + tipjars += new_tipjars if new_tipjars else [] + return [tipjar._asdict() for tipjar in tipjars] if tipjars else [] + + +@tipjar_ext.get("/api/v1/tips") +async def api_get_tips(wallet: WalletTypeInfo = Depends(get_key_type)): + """Return list of all tips assigned to wallet with given invoice key""" + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + tips = [] + for wallet_id in wallet_ids: + new_tips = await get_tips(wallet_id) + tips += new_tips if new_tips else [] + return [tip._asdict() for tip in tips] if tips else [] + + +@tipjar_ext.put("/api/v1/tips/{tip_id}") +async def api_update_tip( + wallet: WalletTypeInfo = Depends(get_key_type), tip_id: str = Query(None) +): + """Update a tip with the data given in the request""" + if tip_id: + tip = await get_tip(tip_id) + + if not tip: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Tip does not exist." + ) + + if tip.wallet != wallet.wallet.id: + + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your tip." + ) + + tip = await update_tip(tip_id, **g.data) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="No tip ID specified" + ) + return tip.dict() + + +@tipjar_ext.put("/api/v1/tipjars/{tipjar_id}") +async def api_update_tipjar( + wallet: WalletTypeInfo = Depends(get_key_type), tipjar_id: str = Query(None) +): + """Update a tipjar with the data given in the request""" + if tipjar_id: + tipjar = await get_tipjar(tipjar_id) + + if not tipjar: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TipJar does not exist." + ) + + if tipjar.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your tipjar." + ) + + tipjar = await update_tipjar(tipjar_id, **data) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="No tipjar ID specified" + ) + return tipjar.dict() + + +@tipjar_ext.delete("/api/v1/tips/{tip_id}") +async def api_delete_tip( + wallet: WalletTypeInfo = Depends(get_key_type), tip_id: str = Query(None) +): + """Delete the tip with the given tip_id""" + tip = await get_tip(tip_id) + if not tip: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="No tip with this ID!" + ) + if tip.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not authorized to delete this tip!", + ) + await delete_tip(tip_id) + + return "", HTTPStatus.NO_CONTENT + + +@tipjar_ext.delete("/api/v1/tipjars/{tipjar_id}") +async def api_delete_tipjar( + wallet: WalletTypeInfo = Depends(get_key_type), tipjar_id: str = Query(None) +): + """Delete the tipjar with the given tipjar_id""" + tipjar = await get_tipjar(tipjar_id) + if not tipjar: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No tipjar with this ID!", + ) + if tipjar.wallet != g.wallet.id: + + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not authorized to delete this tipjar!", + ) + await delete_tipjar(tipjar_id) + + return "", HTTPStatus.NO_CONTENT