diff --git a/lnbits/extensions/subdomains/README.md b/lnbits/extensions/subdomains/README.md new file mode 100644 index 00000000..729f40f4 --- /dev/null +++ b/lnbits/extensions/subdomains/README.md @@ -0,0 +1,54 @@ +

Subdomains Extension

+ +So the goal of the extension is to allow the owner of a domain to sell subdomains to anyone who is willing to pay some money for it. + +[![video tutorial livestream](http://img.youtube.com/vi/O1X0fy3uNpw/0.jpg)](https://youtu.be/O1X0fy3uNpw 'video tutorial subdomains') + +## Requirements + +- Free Cloudflare account +- Cloudflare as a DNS server provider +- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked + +## Usage + +1. Register at Cloudflare and setup your domain with them. (Just follow instructions they provide...) +2. Change DNS server at your domain registrar to point to Cloudflare's +3. Get Cloudflare zone-ID for your domain + +4. Get Cloudflare API TOKEN + + +5. Open the LNBits subdomains extension and register your domain +6. Click on the button in the table to open the public form that was generated for your domain + + - Extension also supports webhooks so you can get notified when someone buys a new subdomain\ + + +## API Endpoints + +- **Domains** + - GET /api/v1/domains + - POST /api/v1/domains + - PUT /api/v1/domains/ + - DELETE /api/v1/domains/ +- **Subdomains** + - GET /api/v1/subdomains + - POST /api/v1/subdomains/ + - GET /api/v1/subdomains/ + - DELETE /api/v1/subdomains/ + +### Cloudflare + +- Cloudflare offers programmatic subdomain registration... (create new A record) +- you can keep your existing domain's registrar, you just have to transfer dns records to the cloudflare (free service) +- more information: + - https://api.cloudflare.com/#getting-started-requests + - API endpoints needed for our project: + - https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records + - https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record + - https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record + - https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record +- api can be used by providing authorization token OR authorization key + - check API Tokens and API Keys : https://api.cloudflare.com/#getting-started-requests +- Cloudflare API postman collection: https://support.cloudflare.com/hc/en-us/articles/115002323852-Using-Cloudflare-API-with-Postman-Collections diff --git a/lnbits/extensions/subdomains/__init__.py b/lnbits/extensions/subdomains/__init__.py new file mode 100644 index 00000000..9701095c --- /dev/null +++ b/lnbits/extensions/subdomains/__init__.py @@ -0,0 +1,28 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_subdomains") + +subdomains_ext: APIRouter = APIRouter( + prefix="/subdomains", + tags=["subdomains"] +) + +def subdomains_renderer(): + return template_renderer(["lnbits/extensions/subdomains/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def subdomains_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) + diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py new file mode 100644 index 00000000..089dbc82 --- /dev/null +++ b/lnbits/extensions/subdomains/cloudflare.py @@ -0,0 +1,60 @@ +from lnbits.extensions.subdomains.models import Domains +import httpx, json + + +async def cloudflare_create_subdomain( + domain: Domains, subdomain: str, record_type: str, ip: str +): + # Call to cloudflare sort of a dry-run - if success delete the domain and wait for payment + ### SEND REQUEST TO CLOUDFLARE + url = ( + "https://api.cloudflare.com/client/v4/zones/" + + domain.cf_zone_id + + "/dns_records" + ) + header = { + "Authorization": "Bearer " + domain.cf_token, + "Content-Type": "application/json", + } + aRecord = subdomain + "." + domain.domain + cf_response = "" + async with httpx.AsyncClient() as client: + try: + r = await client.post( + url, + headers=header, + json={ + "type": record_type, + "name": aRecord, + "content": ip, + "ttl": 0, + "proxied": False, + }, + timeout=40, + ) + cf_response = json.loads(r.text) + except AssertionError: + cf_response = "Error occured" + return cf_response + + +async def cloudflare_deletesubdomain(domain: Domains, domain_id: str): + url = ( + "https://api.cloudflare.com/client/v4/zones/" + + domain.cf_zone_id + + "/dns_records" + ) + header = { + "Authorization": "Bearer " + domain.cf_token, + "Content-Type": "application/json", + } + async with httpx.AsyncClient() as client: + try: + r = await client.delete( + url + "/" + domain_id, + headers=header, + timeout=40, + ) + cf_response = r.text + except AssertionError: + cf_response = "Error occured" diff --git a/lnbits/extensions/subdomains/config.json b/lnbits/extensions/subdomains/config.json new file mode 100644 index 00000000..6bf9480c --- /dev/null +++ b/lnbits/extensions/subdomains/config.json @@ -0,0 +1,6 @@ +{ + "name": "Subdomains", + "short_description": "Sell subdomains of your domain", + "icon": "domain", + "contributors": ["grmkris"] +} diff --git a/lnbits/extensions/subdomains/crud.py b/lnbits/extensions/subdomains/crud.py new file mode 100644 index 00000000..1ca6b66b --- /dev/null +++ b/lnbits/extensions/subdomains/crud.py @@ -0,0 +1,168 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateDomain, Domains, Subdomains + + +async def create_subdomain( + payment_hash, + wallet, + data: CreateDomain +) -> Subdomains: + await db.execute( + """ + INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payment_hash, + data.domain, + data.email, + data.subdomain, + data.ip, + wallet, + data.sats, + data.duration, + False, + data.record_type, + ), + ) + + new_subdomain = await get_subdomain(payment_hash) + assert new_subdomain, "Newly created subdomain couldn't be retrieved" + return new_subdomain + + +async def set_subdomain_paid(payment_hash: str) -> Subdomains: + row = await db.fetchone( + "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", + (payment_hash,), + ) + if row[8] == False: + await db.execute( + """ + UPDATE subdomains.subdomain + SET paid = true + WHERE id = ? + """, + (payment_hash,), + ) + + domaindata = await get_domain(row[1]) + assert domaindata, "Couldn't get domain from paid subdomain" + + amount = domaindata.amountmade + row[8] + await db.execute( + """ + UPDATE subdomains.domain + SET amountmade = ? + WHERE id = ? + """, + (amount, row[1]), + ) + + new_subdomain = await get_subdomain(payment_hash) + assert new_subdomain, "Newly paid subdomain couldn't be retrieved" + return new_subdomain + + +async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]: + row = await db.fetchone( + "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", + (subdomain_id,), + ) + return Subdomains(**row) if row else None + + +async def get_subdomainBySubdomain(subdomain: str) -> Optional[Subdomains]: + row = await db.fetchone( + "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.subdomain = ?", + (subdomain,), + ) + print(row) + return Subdomains(**row) if row else None + + +async def get_subdomains(wallet_ids: Union[str, List[str]]) -> List[Subdomains]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})", + (*wallet_ids,), + ) + + return [Subdomains(**row) for row in rows] + + +async def delete_subdomain(subdomain_id: str) -> None: + await db.execute("DELETE FROM subdomains.subdomain WHERE id = ?", (subdomain_id,)) + + +# Domains + + +async def create_domain( + data: CreateDomain +) -> Domains: + domain_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO subdomains.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, amountmade, allowed_record_types) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + domain_id, + data.wallet, + data.domain, + data.webhook, + data.cf_token, + data.cf_zone_id, + data.description, + data.cost, + 0, + data.allowed_record_types, + ), + ) + + new_domain = await get_domain(domain_id) + assert new_domain, "Newly created domain couldn't be retrieved" + return new_domain + + +async def update_domain(domain_id: str, **kwargs) -> Domains: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE subdomains.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id) + ) + row = await db.fetchone( + "SELECT * FROM subdomains.domain WHERE id = ?", (domain_id,) + ) + assert row, "Newly updated domain couldn't be retrieved" + return Domains(**row) + + +async def get_domain(domain_id: str) -> Optional[Domains]: + row = await db.fetchone( + "SELECT * FROM subdomains.domain WHERE id = ?", (domain_id,) + ) + return Domains(**row) if row else None + + +async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM subdomains.domain WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Domains(**row) for row in rows] + + +async def delete_domain(domain_id: str) -> None: + await db.execute("DELETE FROM subdomains.domain WHERE id = ?", (domain_id,)) diff --git a/lnbits/extensions/subdomains/migrations.py b/lnbits/extensions/subdomains/migrations.py new file mode 100644 index 00000000..292d1f18 --- /dev/null +++ b/lnbits/extensions/subdomains/migrations.py @@ -0,0 +1,41 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE subdomains.domain ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + domain TEXT NOT NULL, + webhook TEXT, + cf_token TEXT NOT NULL, + cf_zone_id TEXT NOT NULL, + description TEXT NOT NULL, + cost INTEGER NOT NULL, + amountmade INTEGER NOT NULL, + allowed_record_types TEXT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE subdomains.subdomain ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL, + email TEXT NOT NULL, + subdomain TEXT NOT NULL, + ip TEXT NOT NULL, + wallet TEXT NOT NULL, + sats INTEGER NOT NULL, + duration INTEGER NOT NULL, + paid BOOLEAN NOT NULL, + record_type TEXT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/subdomains/models.py b/lnbits/extensions/subdomains/models.py new file mode 100644 index 00000000..a8ee6c95 --- /dev/null +++ b/lnbits/extensions/subdomains/models.py @@ -0,0 +1,49 @@ +from fastapi.params import Query +from pydantic.main import BaseModel + + +class CreateDomain(BaseModel): + wallet: str = Query(...) + domain: str = Query(...) + cf_token: str = Query(...) + cf_zone_id: str = Query(...) + webhook: str = Query("") + description: str = Query(..., min_length=0) + cost: int = Query(..., ge=0) + allowed_record_types: str = Query(...) + +class CreateSubdomain(BaseModel): + domain: str = Query(...) + subdomain: str = Query(...) + email: str = Query(...) + ip: str = Query(...) + sats: int = Query(..., ge=0) + duration: int = Query(...) + record_type: str = Query(...) + +class Domains(BaseModel): + id: str + wallet: str + domain: str + cf_token: str + cf_zone_id: str + webhook: str + description: str + cost: int + amountmade: int + time: int + allowed_record_types: str + +class Subdomains(BaseModel): + id: str + wallet: str + domain: str + domain_name: str + subdomain: str + email: str + ip: str + sats: int + duration: int + paid: bool + time: int + record_type: str diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py new file mode 100644 index 00000000..39312fa1 --- /dev/null +++ b/lnbits/extensions/subdomains/tasks.py @@ -0,0 +1,67 @@ +import asyncio + +import httpx + +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .cloudflare import cloudflare_create_subdomain +from .crud import get_domain, set_subdomain_paid + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + +# async def register_listeners(): +# invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) +# register_invoice_listener(invoice_paid_chan_send) +# await wait_for_paid_invoices(invoice_paid_chan_recv) + + +# async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): +# async for payment in invoice_paid_chan: +# await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if "lnsubdomain" != payment.extra.get("tag"): + # not an lnurlp invoice + return + + await payment.set_pending(False) + subdomain = await set_subdomain_paid(payment_hash=payment.payment_hash) + domain = await get_domain(subdomain.domain) + + ### Create subdomain + cf_response = cloudflare_create_subdomain( + domain=domain, + subdomain=subdomain.subdomain, + record_type=subdomain.record_type, + ip=subdomain.ip, + ) + + ### Use webhook to notify about cloudflare registration + if domain.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + domain.webhook, + json={ + "domain": subdomain.domain_name, + "subdomain": subdomain.subdomain, + "record_type": subdomain.record_type, + "email": subdomain.email, + "ip": subdomain.ip, + "cost:": str(subdomain.sats) + " sats", + "duration": str(subdomain.duration) + " days", + "cf_response": cf_response, + }, + timeout=40, + ) + except AssertionError: + webhook = None diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html new file mode 100644 index 00000000..b839c641 --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html @@ -0,0 +1,26 @@ + + + +
+ lnSubdomains: Get paid sats to sell your subdomains +
+

+ Charge people for using your subdomain name...
+ + More details +
+ + Created by, Kris +

+
+
+
diff --git a/lnbits/extensions/subdomains/templates/subdomains/display.html b/lnbits/extensions/subdomains/templates/subdomains/display.html new file mode 100644 index 00000000..e52ac73c --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/display.html @@ -0,0 +1,221 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ domain_domain }}

+
+
{{ domain_desc }}
+
+ + + + + + + + + +

+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %} +

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/templates/subdomains/index.html b/lnbits/extensions/subdomains/templates/subdomains/index.html new file mode 100644 index 00000000..06c80d35 --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/index.html @@ -0,0 +1,550 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + New Domain + + + + + +
+
+
Domains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Subdomains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Subdomain extension +
+
+ + + {% include "subdomains/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + +
+ Update Form + Create Domain + Cancel +
+
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/util.py b/lnbits/extensions/subdomains/util.py new file mode 100644 index 00000000..c7d66307 --- /dev/null +++ b/lnbits/extensions/subdomains/util.py @@ -0,0 +1,36 @@ +from lnbits.extensions.subdomains.models import Subdomains + +# Python3 program to validate +# domain name +# using regular expression +import re +import socket + +# Function to validate domain name. +def isValidDomain(str): + # Regex to check valid + # domain name. + regex = "^((?!-)[A-Za-z0-9-]{1,63}(?