/ app.py
app.py
1 from elements import ( 2 Bottle, 3 route, 4 request, 5 response, 6 run, 7 redirect, 8 config, 9 abort, 10 static_file, 11 Account, 12 SolarPath, 13 WebSession, 14 Picture, 15 Timestamp 16 ) 17 18 from elements.social import ( 19 Booking, 20 Calendar, 21 RSVP, 22 Comment, 23 Place, 24 Reaction 25 ) 26 27 from elements.core.libs.utilities import make_request 28 29 from calendar import monthrange 30 from datetime import date, timedelta 31 from elements.core.libs import chevron 32 from elements.core.plugins import ( 33 Resolver, 34 NostrDB, 35 SessionManager, 36 FlashMessages 37 ) 38 from elements.actions import login, nip05 39 from math import ceil 40 import json 41 42 ############################################### 43 ############################################### 44 ##### ________ __ ___ ___ ##### 45 ##### / __/ __ \/ / / _ | / _ \ ##### 46 ##### _\ \/ /_/ / /__/ __ |/ , _/ ##### 47 ##### /___/\____/____/_/ |_/_/|_| ##### 48 ##### ##### 49 ############################################### 50 ##### Build your own identity. ###### 51 ############################################### 52 53 # Welcome to S☉LAR! 54 55 # I'm glad you're keen to understand it better. 56 # Source code can be dry, but hopefully I can 57 # do a good job of explaining everything that 58 # is going on here. Let's dive in! 59 60 ############################################### 61 ### Application Setup ######################### 62 ############################################### 63 64 # We use Bottle as the foundational web framework. 65 # This function instantiates the application object. 66 app = Bottle() 67 68 SolarPath.subspace = "cred" 69 70 # The "config" object (defined in elements/core/classes/config.py) 71 # loads values from the /etc/solar.conf and ./solar.conf files 72 # on instantiation. We add those to the application config. 73 app.config.update(config) 74 75 # We can also add custom values to the application config like so. 76 # These values are mainly used in the templating engine. 77 app.config.update({ 78 'site_name': 'CREDENSO', 79 'root': '/', 80 'account': Account(config.get('user')), 81 'public': True 82 }) 83 84 # SessionManager is the object that holds all the active sessions 85 # and matches them to active session keys. Installing it adds the 86 # 'sessions' attribute to the app and allows for 87 sessions = SessionManager(auth_path='/login/', secret=config.get('sessions.secret')) 88 app.install(sessions) 89 90 # NostrDB allows for the addition of keyword ('db' by default) 91 # into the route, which allows us to query the relay for data. 92 nostrdb = NostrDB(config.get('solar.database')) 93 app.install(nostrdb) 94 config['db'] = nostrdb.db 95 96 # FlashMessages is what we use to provide basic feedback on 97 # interaction - incorrect passwords, saved changes etc. 98 app.install(FlashMessages()) 99 100 cred = WebSession(config.get('credenso.auth')) 101 calendar = config.get('db').get(f'31924:{cred.account.pubkey}:credenso') 102 103 if calendar is None: 104 calendar = Calendar(d="credenso", content="Events published through Credenso Cafe") 105 calendar.sign(cred) 106 calendar.save() 107 108 calendar.hydrate() 109 110 current_places = config.get('db').query({ 'kinds': [Place.kind] }) 111 112 113 ############################################### 114 ### Rendering ################################# 115 ############################################### 116 117 # Rendering is the process of assembling a page 118 # in response to a request. 119 120 # This rendering function uses the chevron 121 # library to build passed data into webpages. 122 def render(template_name, **kwargs): 123 124 # We look to the "web" namespace under the subspace of 125 # whichever user is running the program for the file. 126 template = SolarPath(namespace="web").read(template_name) 127 128 # If the template file is not found, 129 # render the text literally. 130 if template is None: 131 template = template_name 132 133 # In that same folder, we use the 'components' folder 134 # to include reusable pieces of the site (e.g. comments) 135 components_path = SolarPath('components', namespace="web") 136 137 # These components can be overwritten or customized 138 # by manually passing in the data as a string. 139 components = kwargs.pop('components', {}) 140 141 # Scopes are objects which contain a number of values 142 # which need to be accessed by the renderer (e.g. Event). 143 # We pass them as a "context" 144 context = kwargs.pop('context', None) 145 146 147 if kwargs.pop('fragment', False): 148 pass 149 150 elif 'Hx-Request' not in request.headers and "{{>head}}" not in template: 151 template = "{{>head}}" + template + "{{>foot}}" 152 153 elif kwargs.pop('full', False): 154 template = "{{>head}}" + template + "{{>foot}}" 155 156 157 # This is all of the data which gets passed to the 158 # renderer. It includes the base config, query params, 159 # any submitted form data, and any keyword arguments 160 # passed to the render function. 161 162 def markers(): 163 return json.dumps({ p.name: { 164 'coords': list(reversed(p.coordinates)), 165 'url': p.url, 166 'display_name': p.properties.get('name') 167 } for p in current_places }) 168 169 170 # It also includes data pulled from the "flashes" plugin. 171 render_dict = { 172 'template': template, 173 'partials_dict': components, 174 'partials_path': components_path.fs, 175 'partials_ext': "html", 176 'data': { 177 **app.config, 178 **request.query, 179 **request.forms, 180 **kwargs, 181 'markers': markers(), 182 'flashes': app.get_flashes() 183 } 184 } 185 186 if context: 187 if type(context) is not list: 188 context = [context] 189 190 render_dict['scopes'] = [render_dict['data']] + context 191 192 # Here, we are calling a function from the chevron 193 # library and then returning it. 194 return chevron.render(**render_dict) 195 196 197 ############################################### 198 ### Routes #################################### 199 ############################################### 200 201 # Routes are the possible avenues that someone 202 # can use to access a webpage. 203 204 # When Bottle receives a request, it uses these 205 # to decide what to return. The route below is 206 # the index, or main route. Usually, people 207 # visiting the website will land here first. 208 209 # This route can be referred to by its name 210 # for purposes like redirection. 211 @app.route('/', name="home") 212 def index(session, db): 213 components = {} 214 return render('index.html', components=components, session=session, alt_nav=True, full=True) 215 216 # This is the login page. If someone is already signed in, 217 # it will redirect them to the "home" route. 218 @app.route('/login/', name="login") 219 def login(session): 220 if session: 221 return redirect(app.get_url('home')) 222 223 return render('login.html') 224 225 @app.route('/register/') 226 def register(session): 227 if session: 228 return redirect(app.get_url('home')) 229 230 return render('register.html') 231 232 # This route can be referred to by its name 233 # for purposes like redirection. 234 @app.route('/bookings/', name="bookings") 235 def bookings(session, db): 236 components = { 237 'calendar': get_month(session=session, db=db), 238 'day_events': get_events_for_day(session=session, db=db) 239 } 240 return render('bookings.html', components=components, session=session) 241 242 @app.route('/places/', name="places") 243 def places(session, db): 244 places = db.query({ 'kinds': [Place.kind] }) 245 components = { 246 #'calendar': get_month(session=session, db=db), 247 #'day_events': get_events_for_day(session=session, db=db) 248 } 249 250 return render('places.html', components=components, places=places.events, session=session) 251 252 @app.route('/places/<name>/') 253 def places(name, session, db): 254 places = db.query({ 'kinds': [Place.kind], '#d': [name] }) 255 if len(places) == 0: 256 return redirect(app.get_url('places')) 257 258 upcoming = [e for e in calendar.upcoming() if e.location == name] 259 260 return render('place.html', session=session, upcoming=upcoming, context=places.events[0]) 261 262 @app.route('/people/<name>/') 263 def places(name, session, db): 264 acc = Account(name) 265 if acc.profile.skills: 266 skills = acc.profile.skills.split(',') 267 else: 268 skills = [] 269 now = Timestamp() 270 271 up = [] 272 prev = [] 273 for e in calendar.bookings: 274 p = e.tags.getall('p') 275 for participant in p: 276 if participant[0] == acc.pubkey: 277 if e.start > now: 278 up.append(e) 279 else: 280 prev.append(e) 281 282 return render('member.html', session=session, upcoming=up, skills=skills, previous={"upcoming": prev}, context=acc.profile) 283 284 @app.route('/admin/', allow=['solar']) 285 def admin(session, db): 286 return render('admin.html', session=session) 287 288 @app.route('/admin/<pane>/', allow=['solar']) 289 def admin(pane, session, db): 290 if pane == "places": 291 places = db.query({ 'kinds': [Place.kind] }) 292 return render(f'components/admin/{pane}.html', session=session, places=places.events) 293 294 return render(f'components/admin/{pane}.html', session=session) 295 296 @app.route('/bookings/<name>/') 297 def booking_details(session, db, name): 298 addr = f"{Booking.kind}:{cred.account.pubkey}:{name}" 299 booking = db.get(addr) 300 301 if booking is None: 302 try: 303 [booking] = db.query({ 'kinds': [Booking.kind], '#d': [name] }) 304 except ValueError: 305 abort(404, "Booking not found") 306 307 host = booking.host 308 309 rsvps = db.query({ 'kinds': [RSVP.kind], '#a': [booking.address] }) 310 311 if booking.location == '': 312 place = None 313 else: 314 places = db.query({ 'kinds': [Place.kind], '#d': [booking.location] }) 315 if len(places) > 0: 316 place = places.events[0] 317 318 if session: 319 #booking.auth = booking.pubkey == session.account.pubkey 320 booking.auth = True 321 my_rsvp = rsvps.find(session.account.pubkey, 'pubkey') 322 if my_rsvp: 323 reserved = my_rsvp[0].status 324 else: 325 reserved = None 326 else: 327 reserved = None 328 329 components = {} 330 331 return render("booking.html", context=booking, host=host, session=session, place=place, reserved=reserved, rsvps=rsvps.events) 332 333 @app.route('/about/') 334 def about(session): 335 return render('about.html') 336 337 @app.route('/account/') 338 def about(session): 339 if session is None: 340 return redirect(app.get_url('login')) 341 342 return render('account.html', session=session) 343 344 ############################################### 345 ### Data ###################################### 346 ############################################### 347 348 # We include a generic data endpoint to show 349 # how this system can be used alongside the 350 # actions endpoint to build a robust system. 351 352 #@app.route('/<path:path>/') 353 #def data(path, db): 354 # events = db.resolve(path) 355 # return events.flatten() 356 357 ############################################### 358 ### Static Files ############################## 359 ############################################### 360 361 # Static files do not change over time, which 362 # means that they can be cached on the client's 363 # device. We use custom handling to serve them 364 # without passing through the renderer. 365 366 @app.route('/assets/<filepath:path>') 367 def assets(filepath): 368 static_root = SolarPath('assets', namespace="web") 369 return static_file(filepath, root=static_root.fs) 370 371 @app.route('/images/<filepath:path>') 372 def images(filepath): 373 static_root = SolarPath('images', namespace="web") 374 return static_file(filepath, root=static_root.fs) 375 376 @app.route(f'/uploads/<filepath:path>') 377 def uploads(filepath): 378 static_root = SolarPath('.', namespace=app.config.get('storage.namespace'), subspace=None) 379 return static_file(filepath, root=static_root.fs) 380 381 ############################################### 382 ### Actions ################################### 383 ############################################### 384 385 # In Solar, "actions" represent some interaction 386 # with the site. This endpoint is responsible 387 # for parsing action queries and routing them 388 # to the appropriate callback. 389 390 # Actions can happen relative to a piece of data 391 # or they can happen at the application root. 392 # Actions are always a plaintext, lowercase word 393 394 # These callbacks are automatically added to 395 # the configuration when they are imported. 396 397 def action(func): 398 config['actions'][func.__name__] = func 399 return func 400 401 @action 402 def get_month(*args, **kwargs): 403 calendar_squares = [{ 'day': None }] * 42 404 405 t = Timestamp() 406 407 # If the query includes month and year, replace the current Timestamp 408 if request.query.get('month'): 409 month = int(request.query.get('month')) 410 year = int(request.query.get('year')) 411 d = date(year, month, 1) 412 t = Timestamp.combine(d, Timestamp.min.time(), tzinfo=Timestamp.tz) 413 414 this_month = { 415 'name': t.strftime('%B %Y'), 416 'year': t.Y, 417 'month': t.m, 418 'query': f'?month={t.m}&year={t.Y}' 419 } 420 421 month_name = t.strftime('%B %Y') 422 prev_month = t - timedelta(days= int(t.d) + 1) 423 next_month = t + timedelta(days= 32 - int(t.d)) 424 425 offset, number_of_days = monthrange(t.year, t.month) 426 427 # Offset is the day of week, starting on Monday. 428 # We start on Sunday, so we add one and mod 7 429 offset = (offset + 1) % 7 430 431 432 for i in range(number_of_days): 433 calendar_squares[offset+i] = { 'day': i + 1 } 434 435 start_of_month = Timestamp.combine(date(int(t.Y), int(t.m), 1), Timestamp.min.time(), tzinfo=Timestamp.tz) 436 bookings = calendar.on(start_of_month, days=number_of_days) 437 438 for b in bookings: 439 square = offset + int(b.start.d) - 1 440 calendar_squares[square].update({ 'has_events': True }) 441 442 number_of_weeks = ceil((offset + number_of_days) / 7) 443 444 # Divide the data up into weeks 445 weeks = [{'days': calendar_squares[i*7:(i+1)*7]} for i in range(number_of_weeks)] 446 447 data = { 448 'weeks': weeks, 449 'this_month': this_month, 450 'prev_query': f'?month={prev_month.m}&year={prev_month.Y}', 451 'next_query': f'?month={next_month.m}&year={next_month.Y}', 452 'fragment': True 453 } 454 455 return render('components/calendar.html', **data) 456 457 @action 458 def get_events_for_day(*args, **kwargs): 459 day = None 460 events = None 461 if request.query.get('month'): 462 month = int(request.query.get('month')) 463 year = int(request.query.get('year')) 464 day = int(request.query.get('day')) 465 d = date(year, month, day) 466 day = Timestamp.combine(d, Timestamp.min.time(), tzinfo=Timestamp.tz) 467 events = calendar.on(day) 468 else: 469 events = calendar.upcoming() 470 471 data = { 'day': day, 'events': events, 'fragment': True } 472 return render('components/day_events.html', **data) 473 474 @action 475 def event(*args, **kwargs): 476 global calendar 477 session = kwargs.get('session') 478 db = kwargs.get('db') 479 path = kwargs.get('path') 480 481 if request.method == "GET": 482 now = Timestamp() 483 #accounts = Accounts.all() 484 #places = Places.all() 485 #**defaults({ 'session': s, 'now': now, 'members': accounts.content, 'places': places.content })) 486 return render('forms/event.html', session=session, now=now) 487 488 if request.method == "PUT": 489 now = Timestamp() 490 places = db.query({ 'kinds': [Place.kind] }).events 491 people = Account.all() 492 if path: 493 editing = db.get(path) 494 return render('forms/event.html', session=session, now=editing.start, people=people, places=places, context=editing) 495 else: 496 return render('forms/event.html', session=session, places=places, people=people, now=now) 497 498 499 elif request.method == "POST" or request.method == "PATCH": 500 501 data = dict(request.forms.decode()) 502 date = data.pop('day') 503 time = data.pop('time') 504 host = data.pop('host', None) 505 506 if host: 507 data['p'] = [host, "host"] 508 else: 509 data['p'] = [session.account.pubkey, "host"] 510 511 # Parse a timestamp from the combined date and time 512 start = Timestamp.strptime(f'{date} {time}', '%Y-%m-%d %H:%M') 513 514 # Figure out when it ends 515 duration = int(data.get('duration')) * 300 # 300 seconds per credenso 516 end = Timestamp(int(start) + duration) 517 518 data['start'] = str(int(start)) 519 data['end'] = str(int(end)) 520 521 # Did they pass a file? 522 img = request.files.get('image') 523 524 if request.method == "PATCH": 525 editing = db.get(path) 526 calendar.remove(editing) 527 528 # Retain the previous image if it exists and there is 529 # no new image being passed 530 531 # This does not support multiple images 532 imeta = editing.tags.get('imeta') 533 if imeta and not img: 534 b = Booking.new(**data, d=editing.name, imeta=imeta) 535 else: 536 b = Booking.new(**data, d=editing.name) 537 538 else: 539 b = Booking.new(**data) 540 541 # If an image file was passed, attach it 542 if img: 543 b.attach(img, thumbnail=(400, 300), **kwargs) 544 545 b.sign(cred) 546 b.save(db=db) 547 548 if not request.method == "PATCH": 549 calendar.add(b) 550 calendar.sign(cred) 551 calendar.save(db=db) 552 else: 553 ## (re)hydrate to update information - this isn't particularly 554 ## efficient, but it's workable and only runs when the calendar 555 ## events are being patched 556 calendar = db.get(f'31924:{cred.account.pubkey}:credenso') 557 calendar.hydrate() 558 559 response.set_header('HX-Redirect', app.get_url('bookings') + b.name + '/') 560 #return redirect(b.url) 561 return None 562 563 elif request.method == "DELETE": 564 booking = db.get(path) 565 booking.unsave(session=session, db=db) 566 567 calendar.remove(booking) 568 calendar.sign(cred) 569 calendar.save(db=db) 570 response.set_header('HX-Redirect', app.get_url('bookings')) 571 572 return None 573 574 @action 575 def duplicate(*args, **kwargs): 576 session = kwargs.get('session') 577 db = kwargs.get('db') 578 path = kwargs.get('path') 579 580 ev = db.get(path) 581 582 if request.method == "POST": 583 b = ev.duplicate(weeks=1) 584 b.save(session=session, db=db) 585 calendar.add(b) 586 calendar.sign(cred) 587 calendar.save(db=db) 588 589 response.set_header('HX-Redirect', app.get_url('bookings') + b.name + '/') 590 591 @action 592 def rsvp(*args, **kwargs): 593 session = kwargs.get('session') 594 db = kwargs.get('db') 595 path = kwargs.get('path') 596 render = kwargs.get('render') 597 598 booking = db.get(path) 599 rsvps = db.query({ 'kinds': [RSVP.kind], '#a': [booking.address] }) 600 601 if request.method == "GET": 602 pass 603 604 if request.method == "PUT": 605 return render('''<div id="options" hx-target="#options"> 606 <a class="button icon solid fa-check" hx-post="{{ address }}/rsvp" hx-vals='{"status": "accepted"}' hx-swap="outerHTML">Accept</a> 607 <a class="button icon solid fa-times" hx-post="{{ address }}/rsvp" hx-vals='{"status": "declined"}' hx-swap="outerHTML">Decline</a> 608 <a class="button icon solid fa-question" hx-post="{{ address }}/rsvp" hx-vals='{"status": "tentative"}' hx-swap="outerHTML">Tentative</a> 609 </div>''', context=booking) 610 611 if request.method == "POST": 612 reservation = RSVP.new(**request.forms, event=booking) 613 reservation.save(session=session) 614 rsvps.add(reservation) 615 return render('<a class="button icon primary solid fa-calendar" hx-delete="{{ address }}/rsvp" hx-swap="outerHTML">{{ status }}</a> <ul id="rsvps" hx-swap-oob="true">{{#rsvps}}<li>{{ author.profile.display_name }}: {{status}}</li>{{/rsvps}}</ul>', context=booking, status=reservation.status, rsvps=rsvps.events) 616 617 if request.method == "DELETE": 618 for rsvp in rsvps: 619 if rsvp.pubkey == session.account.pubkey: 620 rsvp.unsave(session=session) 621 rsvps.remove(rsvp) 622 623 return render('<a class="button icon solid fa-calendar" hx-put="{{ address }}/rsvp" hx-swap="outerHTML">RSVP</a><ul id="rsvps" hx-swap-oob="true">{{#rsvps}}<li>{{ author.name }}{{status}}</li>{{/rsvps}}</ul>', context=booking, rsvps=rsvps.events) 624 625 @action 626 def place(*args, **kwargs): 627 session = kwargs.get('session') 628 db = kwargs.get('db') 629 path = kwargs.get('path') 630 631 if request.method == "GET": 632 template_path = Path(tag, 'forms', 'place.mo') 633 return chevron_template('forms/place.html', **defaults()); 634 635 if request.method == "PUT": 636 if path: 637 place = db.get(path) 638 else: 639 place = None 640 641 return render('forms/place.html', session=session, element=place); 642 643 if request.method == "POST": 644 place = Place.new(author=session.account, **request.forms) 645 646 elif request.method == "PATCH": 647 path = kwargs.get('path') 648 place = db.get(path) 649 place.update(request.forms) 650 651 image = request.files.get('image') 652 if image: 653 pic = Picture.upload(image, session) 654 pic.save(session=session) 655 place.content['properties']['image'] = pic.static_url 656 657 place.sign(session) 658 place.save(session=session, db=db) 659 660 return place.flatten() 661 662 #data = { 663 # 'place': p 664 #} 665 #template_path = Path(tag, 'components', 'place.mo') 666 #return chevron_template(str(template_path), **defaults(data)); 667 668 @action 669 def profile(*args, **kwargs): 670 session = kwargs.get('session') 671 672 if request.method == "PATCH": 673 session.account.profile.update(**request.forms) 674 session.account.profile.save(session=session) 675 session.account.profile.store( 676 namespace=session.account.path.namespace, 677 subspace=session.account.name) 678 679 app.flash('Changes saved') 680 return redirect('/account/') 681 682 @action 683 def comments(*args, **kwargs): 684 session = kwargs.get('session') 685 db = kwargs.get('db') 686 path = kwargs.get('path') 687 render = kwargs.get('render') 688 689 event = db.get(path) 690 691 if request.method == "GET": 692 comments = db.query({ 'kinds': [1111], '#A': [event.address] }) 693 694 top_level = [] 695 replies = {} 696 697 comment_ids = [] 698 699 # Organize between top-level and replies 700 for c in comments: 701 if c.E == c.e or c.A == c.a: 702 top_level.append(c) 703 elif replies.get(c.e): 704 replies[c.e].append(c) 705 else: 706 replies[c.e] = [c] 707 708 comment_ids.append(c.id) 709 710 # Query for reactions here 711 reactions = db.query({ 'kinds': [Reaction.kind], '#e': comment_ids }) 712 713 # Add relevant information to the comments 714 for c in comments: 715 c.reactions = reactions.find(c.id, 'e') 716 if session: 717 c.delete = c.pubkey == session.account.pubkey 718 719 # Find out if the current user has already liked this comment. 720 if c.reactions is not None: 721 for reaction in c.reactions: 722 if c.pubkey == session.account.pubkey: 723 c.liked = True 724 725 if c.pubkey == cred.account.pubkey: 726 c.delete = True 727 728 c.replies = replies.get(c.id) 729 730 kwargs['comments'] = top_level 731 return render("components/comments.html", **kwargs, context=event) 732 733 if request.method == "POST": 734 comment = Comment(**request.forms) 735 comment.reference(event) 736 res = comment.save(session=session) 737 738 return render("components/comment.html", context=comment) 739 740 if request.method == "PUT": 741 return render("forms/comment.html", context=event) 742 743 if request.method == "DELETE": 744 if event.pubkey == cred.account.pubkey: 745 event.unsave() 746 elif event.pubkey == session.account.pubkey: 747 event.unsave(session=session) 748 else: 749 event.unsave() 750 751 @action 752 def chat(*args, **kwargs): 753 session = kwargs.get('session') 754 if session is None: 755 return None 756 757 if type(session) is WebSession: 758 nsec = session.keypair.nsec 759 else: 760 nsec = session.integration('nostr').nsec 761 762 chat_url = f'https://chat.credenso.cafe#nostr-login={nsec}' 763 response.set_header('HX-Redirect', chat_url) 764 return redirect(chat_url) 765 766 @action 767 def zap(*args, **kwargs): 768 # Requires lnbits.cookie to be set in config 769 session = kwargs.get('session') 770 if session is None: 771 return None 772 773 lnbits_id = session.account.path.read('.solar/lnbits_id') 774 775 if lnbits_id is None: 776 body = { 777 "username": session.account.name, 778 "pubkey": session.account.pubkey, 779 "extra": { 780 "display_name": session.account.profile.display_name 781 } 782 } 783 784 headers = { 785 "Content-Type": "application/json", 786 "Cookie": f'cookie_access_token={config.get("lnbits.cookie")}' 787 } 788 789 res = make_request('https://ln.credenso.cafe/users/api/v1/user', 790 method="POST", 791 headers=headers, 792 body=body) 793 794 account = json.loads(res.read()) 795 796 lnbits_id = account.get('id') 797 798 ## Registering lnurlp for easier sending. ## 799 800 # first we get the wallet, 801 wallet_res = make_request(f'https://ln.credenso.cafe/api/v1/wallets?usr={lnbits_id}', 802 headers = {"Content-Type": "application/json"} 803 ) 804 805 [wallet] = json.loads(wallet_res.read()) 806 807 808 # Then we use the wallet's admin key to make a LNURL 809 lnurl_res = make_request(f'https://ln.credenso.cafe/lnurlp/api/v1/links', 810 method = "POST", 811 headers = {"Content-Type": "application/json", 812 "X-Api-Key": wallet.get('adminkey') }, 813 body = { "description": "pay address", 814 "min": 1, 815 "max": 1000000, 816 "username": session.account.name 817 } 818 ) 819 820 lnurl = json.loads(lnurl_res.read()) 821 822 session.account.profile.update(lnurlp=lnurl.get('lnurl')) 823 session.account.profile.save(session=session) 824 825 #session.account.store(lnbits_id=lnbits_id) 826 with open(session.account.path.fs / ".solar" / "lnbits_id", "w") as f: 827 f.write(lnbits_id) 828 829 830 831 ln_url = f'https://ln.credenso.cafe/wallet?usr={lnbits_id}' 832 response.set_header('HX-Redirect', ln_url) 833 return redirect(ln_url) 834 835 @action 836 def like(*args, **kwargs): 837 session = kwargs.get('session') 838 db = kwargs.get('db') 839 path = kwargs.get('path') 840 render = kwargs.get('render') 841 842 # TODO: Path resolution should be improved. 843 if len(path) == 64: 844 event = db.get(path) 845 else: 846 [event] = db.resolve(SolarPath(path) / 'd', subspaces=["*"]) 847 848 if request.method == "GET": 849 likes = db.query({ 'kinds': [Reaction.kind], '#e': [event.id] }) 850 851 if request.method == "POST": 852 r = Reaction(**request.forms, e=event.id) 853 res = r.save(session=session) 854 855 return f'<a class="button solid icon primary fa-hand-paper" hx-delete="{ event.id }/like" hx-swap="outerHTML">Wave</a>' 856 857 if request.method == "DELETE": 858 likes = db.query({ 'kinds': [Reaction.kind], '#e': [event.id], 'authors': [session.account.pubkey] }) 859 for like in likes: 860 like.unsave(session=session) 861 return f'<a class="button solid icon fa-hand-paper" hx-post="{ event.id }/like" hx-swap="outerHTML">Wave</a>' 862 863 864 config['actions']['like'] = like 865 866 @action 867 def register(*args, **kwargs): 868 session = kwargs.get('session') 869 870 if session: 871 return redirect(app.get_url('home')) 872 873 name = request.forms.get('name') 874 875 try: 876 # Don't register if the account already exists 877 account = Account(name) 878 app.flash(f'Account "{name}" already exists') 879 return redirect('/register/') 880 881 except ValueError: 882 pass 883 884 account = Account.register(name=name) 885 sessions.login(name, name, True) 886 887 return redirect(app.get_url('home')) 888 889 @action 890 def logout(*args, **kwargs): 891 sessions.logout() 892 893 return redirect(app.get_url('home')) 894 895 896 app.route('/.well-known/nostr.json', 'GET', nip05) 897 898 @app.route('/<path:path>/<action>', method=['ANY']) 899 @app.route('/<action>', method=['ANY']) 900 def actions(action, session, db, path=None): 901 callback = config['actions'].get(action) 902 #if path is None: 903 # # Try to get the path from the request environment 904 # # if it isn't passed explicitly 905 # referrer = request.environ.get('HTTP_REFERER') 906 # origin = request.environ.get('HTTP_ORIGIN') 907 908 # if origin and referrer: 909 # # We don't want the leading '/' so we lstrip it 910 # path = referrer.replace(origin, '').lstrip('/') 911 912 if callback: 913 return callback(app=app, path=path, session=session, db=db, render=render) 914 else: 915 return abort(404, f"Action '{action}' not found") 916 917 ############################################### 918 ### Running the app ########################### 919 ############################################### 920 921 if __name__ == "__main__": 922 app.run(host="localhost", port=8080, debug=True, reloader=True)