/ 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)