├── .gitmodules ├── bija ├── templates │ ├── upd.json │ ├── alerts │ │ ├── mention.html │ │ ├── follow.html │ │ ├── unfollow.html │ │ ├── reply.html │ │ ├── thread_comment.html │ │ └── reaction.html │ ├── note.og2.html │ ├── note.content.html │ ├── note.video.html │ ├── profile │ │ ├── following.feed.html │ │ ├── profile.sharer.html │ │ ├── following.html │ │ ├── profile.tools.html │ │ ├── profile.image.html │ │ ├── profile.lightning.html │ │ ├── profile.brief.html │ │ ├── profile.html │ │ └── profile.header.html │ ├── topics.html │ ├── share.popup.html │ ├── svg │ │ ├── lightning.svg │ │ ├── edit.svg │ │ ├── like.svg │ │ ├── liked.svg │ │ ├── reply.svg │ │ ├── block.svg │ │ ├── left-arrow.svg │ │ ├── right-arrow.svg │ │ ├── home.svg │ │ ├── up.svg │ │ ├── note-tools.svg │ │ ├── unfollow.svg │ │ ├── following.svg │ │ ├── menu.svg │ │ ├── logout.svg │ │ ├── profile.svg │ │ ├── verified.svg │ │ ├── warn.svg │ │ ├── web.svg │ │ ├── follow.svg │ │ ├── hashtag.svg │ │ ├── reshare.svg │ │ ├── messages.svg │ │ ├── share.svg │ │ ├── lists.svg │ │ ├── bookmarks.svg │ │ ├── notif.svg │ │ ├── emoji.svg │ │ ├── expand.svg │ │ └── settings.svg │ ├── lists.html │ ├── css.html │ ├── delete.confirm.html │ ├── lists.list.html │ ├── thread.placeholder.html │ ├── alerts.html │ ├── restart.html │ ├── list.add.html │ ├── feed │ │ ├── feed.html │ │ └── feed.items.html │ ├── topic.list.html │ ├── search.html │ ├── deleted.note.html │ ├── keys.html │ ├── list.html │ ├── note.form.html │ ├── note.og.html │ ├── topic.html │ ├── block.confirm.html │ ├── thread.item.html │ ├── privkey.html │ ├── message_thread.html │ ├── quote.form.html │ ├── ln.invoice.html │ ├── list.members.html │ ├── message_thread.items.html │ ├── boosts.html │ ├── relays.list.html │ ├── note.reply_form.html │ ├── note.html │ ├── messages.html │ ├── thread.html │ ├── base.html │ ├── login.html │ └── settings.html ├── static │ ├── at.png │ ├── bija.png │ ├── blank.png │ ├── favicon.ico │ ├── lightning.png │ ├── arrow1.svg │ ├── copy.svg │ ├── eye.svg │ ├── close.svg │ ├── speech-bubble.svg │ ├── img.svg │ ├── eye-off.svg │ ├── user.svg │ ├── login.js │ └── qr.svg ├── task_kinds.py ├── active_events.py ├── ws │ ├── message_type.py │ ├── subscription.py │ ├── pow.py │ ├── relay_manager.py │ ├── event.py │ ├── filter.py │ ├── message_pool.py │ ├── key.py │ ├── relay.py │ ├── bech32.py │ └── subscription_manager.py ├── alerts.py ├── app.py ├── args.py ├── settings.py ├── password.py ├── deferred_tasks.py ├── config.py ├── setup.py ├── search.py ├── nip5.py ├── ogtags.py ├── models.py ├── helpers.py ├── notes.py ├── submissions.py └── subscriptions.py ├── .gitignore ├── docker-compose.yml ├── cli.py ├── Dockerfile ├── requirements.txt ├── LICENSE.md ├── README.md └── lightning ├── bech32.py └── lightning_address.py /.gitmodules: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /bija/templates/upd.json: -------------------------------------------------------------------------------- 1 | {{data}} -------------------------------------------------------------------------------- /bija/templates/alerts/mention.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/note.og2.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /bija/static/at.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrightonBTC/bija/HEAD/bija/static/at.png -------------------------------------------------------------------------------- /bija/templates/note.content.html: -------------------------------------------------------------------------------- 1 | {{note['content'] | process_note_content(none) |safe}} -------------------------------------------------------------------------------- /bija/static/bija.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrightonBTC/bija/HEAD/bija/static/bija.png -------------------------------------------------------------------------------- /bija/static/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrightonBTC/bija/HEAD/bija/static/blank.png -------------------------------------------------------------------------------- /bija/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrightonBTC/bija/HEAD/bija/static/favicon.ico -------------------------------------------------------------------------------- /bija/static/lightning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrightonBTC/bija/HEAD/bija/static/lightning.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.sqlite 2 | /bija.sqlite-journal 3 | /flask_session/ 4 | /.idea/ 5 | /bija/flask_session/ 6 | __pycache__ 7 | -------------------------------------------------------------------------------- /bija/task_kinds.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | class TaskKind(IntEnum): 4 | FETCH_OG = 1 5 | VALIDATE_NIP5 = 2 -------------------------------------------------------------------------------- /bija/templates/note.video.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | web: 5 | build: . 6 | ports: 7 | - "5000:5000" 8 | volumes: 9 | - ./:/app -------------------------------------------------------------------------------- /bija/templates/profile/following.feed.html: -------------------------------------------------------------------------------- 1 |
2 | {%- for profile in profiles: -%} 3 | {%- include 'profile/profile.brief.html' -%} 4 | {%- endfor -%} 5 |
-------------------------------------------------------------------------------- /bija/templates/topics.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | {%- include 'topic.list.html' -%} 8 | {%- endblock content -%} -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | from bija.app import main 2 | 3 | from gevent.pywsgi import WSGIServer 4 | 5 | if __name__ == '__main__': 6 | app = main() 7 | http_server = WSGIServer(("0.0.0.0", 5000), app) 8 | http_server.serve_forever() 9 | -------------------------------------------------------------------------------- /bija/templates/profile/profile.sharer.html: -------------------------------------------------------------------------------- 1 |
2 |
{{bech32|QR|safe}}
3 |

npub {{bech32}}

4 |

hex {{hex}}

5 |
-------------------------------------------------------------------------------- /bija/templates/share.popup.html: -------------------------------------------------------------------------------- 1 |

{{'share'| svg_icon('icon-lg')|safe}} Share this note

2 |
3 |

Share the following address with friends or paste it in to a note:

4 | 5 |
-------------------------------------------------------------------------------- /bija/templates/svg/lightning.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/lists.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 |

Lists

8 |
9 | {%- include 'lists.list.html' -%} 10 |
11 | {%- endblock content -%} -------------------------------------------------------------------------------- /bija/templates/css.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/like.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/liked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/delete.confirm.html: -------------------------------------------------------------------------------- 1 |

Please confirm that you wish to delete this note.

2 |
3 | 4 | 5 | 6 |
-------------------------------------------------------------------------------- /bija/templates/lists.list.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/reply.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/thread.placeholder.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Event: {{id}} not yet seen on network
4 |
-------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine3.17 2 | 3 | WORKDIR /app 4 | 5 | RUN apk update && apk add pkgconfig python3-dev build-base cairo-dev cairo cairo-tools git 6 | 7 | COPY requirements.txt ./ 8 | 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | EXPOSE 5000 12 | 13 | COPY . . 14 | 15 | CMD [ "python", "cli.py" ] -------------------------------------------------------------------------------- /bija/templates/svg/block.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/profile/following.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | {%- include 'profile/profile.header.html' -%} 8 |
9 | {%- include 'profile/following.feed.html' -%} 10 |
11 | {%- endblock content -%} -------------------------------------------------------------------------------- /bija/static/arrow1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/static/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/left-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/right-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/alerts.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | 8 | 16 | {%- endblock content -%} -------------------------------------------------------------------------------- /bija/templates/svg/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/static/eye.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/restart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 9 | 10 |
11 |

Restart Required

12 |
13 |

You'll need to close and restart the app to continue.

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /bija/active_events.py: -------------------------------------------------------------------------------- 1 | 2 | class ActiveEvents: 3 | 4 | def __init__(self): 5 | self.notes = set() 6 | self.profiles = set() 7 | 8 | def add_notes(self, notes: list): 9 | self.notes = self.notes.union(notes) 10 | 11 | def add_profiles(self, profiles: list): 12 | self.profiles = self.profiles.union(profiles) 13 | 14 | def clear(self): 15 | self.notes = set() 16 | self.profiles = set() 17 | 18 | -------------------------------------------------------------------------------- /bija/templates/svg/note-tools.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/list.add.html: -------------------------------------------------------------------------------- 1 |

Add to list

2 |
3 | 8 | 9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /bija/templates/feed/feed.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | 8 |

Recent activity in your network

9 |
10 | {%- include 'feed/feed.items.html' -%} 11 |
12 | {%- endblock content -%} 13 | 14 | {%- block right_content -%} 15 | {%- include 'note.form.html' -%} 16 | {%- include 'topic.list.html' -%} 17 | {%- endblock right_content -%} -------------------------------------------------------------------------------- /bija/templates/profile/profile.tools.html: -------------------------------------------------------------------------------- 1 | {%- if is_me and page_id == 'profile-me' -%} 2 | {{'edit'| svg_icon('icon-sm')|safe}} 3 | {%- elif am_following and not is_me -%} 4 | − unfollow 5 | {%- elif not is_me -%} 6 | + follow 7 | {%- endif -%} -------------------------------------------------------------------------------- /bija/templates/svg/unfollow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/following.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/logout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/topic.list.html: -------------------------------------------------------------------------------- 1 |

Topics {{'edit'| svg_icon('icon-sm right edit-tags')|safe}}

2 | -------------------------------------------------------------------------------- /bija/ws/message_type.py: -------------------------------------------------------------------------------- 1 | class ClientMessageType: 2 | EVENT = "EVENT" 3 | REQUEST = "REQ" 4 | CLOSE = "CLOSE" 5 | 6 | class RelayMessageType: 7 | EVENT = "EVENT" 8 | NOTICE = "NOTICE" 9 | OK = "OK" 10 | END_OF_STORED_EVENTS = "EOSE" 11 | 12 | @staticmethod 13 | def is_valid(type: str) -> bool: 14 | if type == RelayMessageType.EVENT or type == RelayMessageType.NOTICE or type == RelayMessageType.END_OF_STORED_EVENTS or type == RelayMessageType.OK: 15 | return True 16 | return False -------------------------------------------------------------------------------- /bija/templates/search.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | {%- if message -%} 8 |

{{message}}

9 | {%- endif -%} 10 |

Searching stored notes for: 11 | {{search}} 12 |

13 |
14 |
15 | {%- include 'feed/feed.items.html' -%} 16 |
17 |
18 | {%- endblock content -%} -------------------------------------------------------------------------------- /bija/templates/svg/profile.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/verified.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/warn.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/web.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /bija/static/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/deleted.note.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 |
8 |
This note was deleted by the author:
 9 |         Reason given: [{{item['content'] |safe}}]
10 |         id: {{item['id']}}
11 |
12 |
13 |
-------------------------------------------------------------------------------- /bija/templates/keys.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
Public{{k['public']}}
Private{{k['private']}}
18 |
19 | 20 |
21 |
22 | 23 | 24 | {%- endblock content -%} -------------------------------------------------------------------------------- /bija/templates/svg/follow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/hashtag.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/ws/subscription.py: -------------------------------------------------------------------------------- 1 | from bija.ws.filter import Filters 2 | 3 | 4 | class Subscription: 5 | def __init__(self, sub_id: str, filters: Filters = None, relay=None, batch=0) -> None: 6 | self.id = sub_id 7 | self.filters = filters 8 | self.relay = relay 9 | self.batch = batch 10 | self.paused = False 11 | 12 | def to_json_object(self): 13 | return { 14 | "id": self.id, 15 | # "filters": self.filters.to_json_array(), 16 | "relay": self.relay, 17 | "batch": self.batch, 18 | "paused": self.paused 19 | } 20 | -------------------------------------------------------------------------------- /bija/static/speech-bubble.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/alerts.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import IntEnum 3 | 4 | from bija.app import app 5 | from bija.db import BijaDB 6 | 7 | DB = BijaDB(app.session) 8 | 9 | 10 | class AlertKind(IntEnum): 11 | REPLY = 0 12 | MENTION = 1 13 | REACTION = 3 14 | COMMENT_ON_THREAD = 4 15 | FOLLOW = 5 16 | UNFOLLOW = 6 17 | 18 | 19 | class Alert: 20 | 21 | def __init__(self, kind: AlertKind, ts, data): 22 | self.kind = kind 23 | self.ts = ts 24 | self.data = data 25 | self.store() 26 | 27 | 28 | def store(self): 29 | DB.add_alert(self.kind, self.ts, json.dumps(self.data)) 30 | -------------------------------------------------------------------------------- /bija/templates/svg/reshare.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/list.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | 8 |

{{title}}

9 |
10 |
11 | {%- include 'feed/feed.items.html' -%} 12 |
13 | {%- endblock content -%} 14 | 15 | {%- block right_content -%} 16 |
17 |
18 | {%- include 'lists.list.html' -%} 19 |
20 |
21 | {%- endblock right_content -%} 22 | -------------------------------------------------------------------------------- /bija/templates/alerts/follow.html: -------------------------------------------------------------------------------- 1 | {%- if data.profile.pic is not none -%} 2 | {%- set pic=data.profile.pic -%} 3 | {%- else -%} 4 | {%- set pic="/identicon?id="+data.profile.public_key -%} 5 | {%- endif -%} 6 | 7 |
8 | 9 |
10 |
11 |
12 | New follower detected
{{data.profile['name'] | ident_string(data.profile['display_name'], data.profile['public_key']) | safe }} 13 |
14 |
15 |
-------------------------------------------------------------------------------- /bija/static/img.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/alerts/unfollow.html: -------------------------------------------------------------------------------- 1 | {%- if data.profile.pic is not none -%} 2 | {%- set pic=data.profile.pic -%} 3 | {%- else -%} 4 | {%- set pic="/identicon?id="+data.profile.public_key -%} 5 | {%- endif -%} 6 | 7 |
8 | 9 |
10 |
11 |
12 | {{data.profile['name'] | ident_string(data.profile['display_name'], data.profile['public_key']) | safe }}
is no longer following you 13 |
14 |
15 |
-------------------------------------------------------------------------------- /bija/templates/svg/messages.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/profile/profile.image.html: -------------------------------------------------------------------------------- 1 | 2 | {%- if p['pic'] is not none and p['pic']|length > 0 -%} 3 | 11 | {%- else -%} 12 | 18 | {%- endif -%} 19 | 20 | -------------------------------------------------------------------------------- /bija/templates/svg/share.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/note.form.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{'expand'| svg_icon('icon-sm maximise')|safe}} 5 | 6 |
7 | 8 | {{'emoji'| svg_icon('icon-lg emojis')|safe}} 9 |
10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /bija/templates/alerts/reply.html: -------------------------------------------------------------------------------- 1 | {%- if data.profile.pic is not none -%} 2 | {%- set pic=data.profile.pic -%} 3 | {%- else -%} 4 | {%- set pic="/identicon?id="+data.profile.public_key -%} 5 | {%- endif -%} 6 | 7 |
8 | 9 |
10 |
11 |
12 | {{data.profile['name'] | ident_string(data.profile['display_name'], data.profile['public_key']) | safe }} replied to your post 13 |
14 |
15 | {{data.content[:500]| striptags}} 16 |
17 |
18 |
-------------------------------------------------------------------------------- /bija/templates/note.og.html: -------------------------------------------------------------------------------- 1 | {%- if 'url' in data -%} 2 | 3 | {%- else -%} 4 |
5 | {%- endif -%} 6 | {%- if 'title' in data -%} 7 |

{{data['title']}}

8 | {%- endif -%} 9 | {%- if 'description' in data -%} 10 |

{{data['description']|truncate(300, False, '...', 0)}}

11 | {%- endif -%} 12 | {%- if 'image' in data -%} 13 | 14 | {%- endif -%} 15 | {%- if 'url' not in data -%} 16 |
17 | {%- else -%} 18 | {{data['url']}} 19 |
20 | {%- endif -%} -------------------------------------------------------------------------------- /bija/templates/alerts/thread_comment.html: -------------------------------------------------------------------------------- 1 | {%- if data.profile.pic is not none -%} 2 | {%- set pic=data.profile.pic -%} 3 | {%- else -%} 4 | {%- set pic="/identicon?id="+data.profile.public_key -%} 5 | {%- endif -%} 6 | 7 |
8 | 9 |
10 |
11 |
12 | {{data.profile['name'] | ident_string(data.profile['display_name'], data.profile['public_key']) | safe }} commented on your thread 13 |
14 |
15 | {{data['content'][:500]| striptags}} 16 |
17 |
18 |
-------------------------------------------------------------------------------- /bija/templates/profile/profile.lightning.html: -------------------------------------------------------------------------------- 1 |

Send @{{name}} a tip with {{'lightning'| svg_icon('icon lightning')|safe}} lightning

2 |
3 |
4 | {%- if data['lud16'] is not none and data['lud16']|length > 0 -%} 5 |

Lightning Address
{{data['lud16']}}

6 | {%- endif -%} 7 | {%- if data['lud06'] is not none and data['lud06']|length > 0 -%} 8 |

LNURL
{{data['lud06']}}

9 | {%- endif -%} 10 |
11 |
12 | {%- if data['lud06'] is not none and data['lud06']|length > 0 -%} 13 | {{data['lud06']|QR|safe}} 14 | {%- endif -%} 15 |
16 |
-------------------------------------------------------------------------------- /bija/templates/topic.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | {%- if subscribed == 1 -%} 8 | {%- set btn_text = 'unsubscribe' -%} 9 | {%- else -%} 10 | {%- set btn_text = 'subscribe' -%} 11 | {%- endif-%} 12 |

Recent activity in topic 13 | #{{topic}} 14 | {{btn_text}} 15 |

16 | Load new posts 17 |
18 | {%- include 'feed/feed.items.html' -%} 19 |
20 | 21 | {%- endblock content -%} 22 | 23 | {%- block right_content -%} 24 | {%- include 'topic.list.html' -%} 25 | {%- endblock right_content -%} -------------------------------------------------------------------------------- /bija/templates/svg/lists.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/static/eye-off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/block.confirm.html: -------------------------------------------------------------------------------- 1 |

Please confirm that you wish to block this user.

2 |
3 |
4 | 5 |
6 | {%- set p=profile -%} 7 | {%- include 'profile/profile.image.html' -%} 8 |
9 |
10 | {{profile['name'] | ident_string(profile['display_name'], profile['public_key']) | safe }} 11 |
{{ p['public_key'] | relationship | safe }} 12 |
13 |
14 |
15 |

{{profile['public_key']}}

16 |
17 | 18 | 19 |
-------------------------------------------------------------------------------- /bija/templates/thread.item.html: -------------------------------------------------------------------------------- 1 | {%- set reply_chain = item['thread_root'] | get_thread_root(item['response_to'], item['id']) -%} 2 | {%- if item['current'] -%} 3 | 4 | {%- endif -%} 5 | {%- if item['deleted'] -%} 6 | {%- include 'deleted.note.html' -%} 7 | {%- else -%} 8 |
9 |
10 |
11 | {{item['nip05'] | nip05_valid(item['nip05_validated']) | safe }} 12 | {%- set p=item -%} 13 | {%- include 'profile/profile.image.html' -%} 14 |
15 |
16 | {%- set note=item -%} 17 | {%- include 'note.html' -%} 18 |
19 |
20 | {%- endif -%} -------------------------------------------------------------------------------- /bija/static/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/app.py: -------------------------------------------------------------------------------- 1 | from flask_socketio import SocketIO 2 | from flask import Flask 3 | from sqlalchemy.orm import scoped_session 4 | from engineio.async_drivers import gevent 5 | import bija.db as db 6 | from bija.active_events import ActiveEvents 7 | from bija.args import args 8 | from bija.ws.relay_manager import RelayManager 9 | 10 | 11 | app = Flask(__name__, template_folder='../bija/templates') 12 | socketio = SocketIO(app) 13 | app.session = scoped_session(db.DB_SESSION) 14 | app.jinja_env.trim_blocks = True 15 | app.jinja_env.lstrip_blocks = True 16 | ACTIVE_EVENTS = ActiveEvents() 17 | RELAY_MANAGER = RelayManager() 18 | 19 | 20 | from bija.routes import * 21 | 22 | 23 | def main(): 24 | print("Bija is now running at http://localhost:{}".format(args.port)) 25 | socketio.run(app, host="0.0.0.0", port=args.port) 26 | 27 | 28 | if __name__ == '__main__': 29 | main() 30 | -------------------------------------------------------------------------------- /bija/templates/svg/bookmarks.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/notif.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/privkey.html: -------------------------------------------------------------------------------- 1 | {%- if passed -%} 2 |

3 | Mnemonic phrase (preferred) 4 | {{k['private'][2]}} 5 |

6 |

7 | nsec 8 | {{k['private'][1]}} 9 | 10 |

11 |

12 | HEX 13 | {{k['private'][0]}} 14 | 15 | (old style) 16 |

17 | {%- else -%} 18 |

Please enter your password

19 |
20 | 21 |
22 | {%- endif -%} -------------------------------------------------------------------------------- /bija/templates/alerts/reaction.html: -------------------------------------------------------------------------------- 1 | {%- if data.profile.pic is not none -%} 2 | {%- set pic=data.profile.pic -%} 3 | {%- else -%} 4 | {%- set pic="/identicon?id="+data.profile.public_key -%} 5 | {%- endif -%} 6 | {%- if data.reaction == '+' or data.reaction == '' -%} 7 | {%- set content = "🤍" -%} 8 | {%- else -%} 9 | {%- set content = data.reaction -%} 10 | {%- endif -%} 11 | 12 |
13 | {{content}} 14 |
15 |
16 |
17 | {{data.profile['name'] | ident_string(data.profile['display_name'], data.profile['public_key']) | safe }} reacted 18 |
19 |
20 | {{data.note['content'][:210]| striptags}} 21 |
22 |
23 |
-------------------------------------------------------------------------------- /bija/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | 4 | from bija.setup import setup 5 | 6 | SETUP_PK = None 7 | SETUP_PW = None 8 | LOGGING_LEVEL = logging.ERROR 9 | 10 | parser = argparse.ArgumentParser() 11 | 12 | parser.add_argument("-s", "--setup", dest="setup", help="Load or create a new profile (private/public key pair)", 13 | action='store_true') 14 | parser.add_argument("-d", "--debug", dest="debug", help="When set debug messages will printed to the terminal", 15 | action='store_true') 16 | parser.add_argument("-p", "--port", dest="port", help="Set the port, default is 5000", default=5000, type=int) 17 | parser.add_argument("-db", "--db", dest="db", help="Set the database - eg. {name}.sqlite, default is bija", 18 | default='bija', type=str) 19 | 20 | args = parser.parse_args() 21 | 22 | if args.setup: 23 | SETUP_PK, SETUP_PW = setup() 24 | 25 | if args.debug: 26 | LOGGING_LEVEL = logging.INFO 27 | -------------------------------------------------------------------------------- /bija/static/login.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load", function () { 2 | const pw_form = document.querySelector('.pw_form') 3 | if(pw_form){ 4 | pw_form.style.display = 'none' 5 | const setup_btns = document.querySelectorAll('input.setup') 6 | for(const btn of setup_btns){ 7 | btn.addEventListener("click", (event)=>{ 8 | event.preventDefault(); 9 | event.stopPropagation(); 10 | pw_form.style.display = 'block' 11 | document.querySelector('.step1').style.display = 'none' 12 | }); 13 | } 14 | } 15 | const inp = document.querySelector('input') 16 | if(inp){ 17 | inp.focus() 18 | } 19 | 20 | }); 21 | const n_checked = function(){ 22 | let n = 0; 23 | const relay_cbs = document.querySelectorAll(".relay_cb"); 24 | for (var i = 0 ; i < relay_cbs.length; i++){ 25 | if(relay_cbs[i].checked) n++; 26 | } 27 | return n 28 | } -------------------------------------------------------------------------------- /bija/templates/message_thread.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 |
8 | {%- include 'message_thread.items.html' -%} 9 |
10 |
11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 | {%- endblock content -%} 19 | 20 | {%- block right_content -%} 21 | {%- set profile=them -%} 22 |
{%- include 'profile/profile.brief.html' -%}
23 | {%- if not inbox -%} 24 | 25 | {%- endif -%} 26 | {%- endblock right_content -%} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.3 2 | bidict==0.22.0 3 | cachelib==0.9.0 4 | certifi==2022.12.7 5 | cffi==1.15.1 6 | charset-normalizer==2.1.1 7 | click==8.1.3 8 | cryptography==38.0.4 9 | Flask==2.2.2 10 | Flask-Executor==1.0.0 11 | Flask-SocketIO==5.3.2 12 | gevent==22.10.2 13 | gevent-websocket==0.10.1 14 | greenlet==2.0.1 15 | idna==3.4 16 | itsdangerous==2.1.2 17 | Jinja2==3.1.2 18 | Markdown==3.4.1 19 | MarkupSafe==2.1.1 20 | pbd==0.9 21 | Pillow==9.3.0 22 | proxy-tools==0.1.0 23 | pycparser==2.21 24 | pydenticon==0.3.1 25 | pyinstaller==5.7.0 26 | pyinstaller-hooks-contrib==2022.14 27 | python-engineio==4.3.4 28 | python-socketio==5.7.2 29 | pywebview==3.7.2 30 | rel==0.4.7 31 | requests==2.28.1 32 | secp256k1==0.14.0 33 | SQLAlchemy==1.4.45 34 | urllib3==1.26.13 35 | websocket-client==1.5.0 36 | Werkzeug==2.2.2 37 | zope.event==4.5.0 38 | zope.interface==5.5.2 39 | 40 | beautifulsoup4~=4.11.1 41 | validators~=0.20.0 42 | bip39~=0.0.2 43 | arrow~=1.2.3 44 | cloudinary~=1.30.0 45 | qrcode==7.3.1 46 | bech32==1.2.0 47 | base58==2.1.1 48 | bitstring==4.0.1 49 | 50 | websocket~=0.2.1 -------------------------------------------------------------------------------- /bija/settings.py: -------------------------------------------------------------------------------- 1 | from bija.app import app 2 | from bija.config import default_settings, themes 3 | from bija.db import BijaDB 4 | 5 | DB = BijaDB(app.session) 6 | 7 | class BijaSettings: 8 | 9 | items = {} 10 | 11 | def set_from_db(self): 12 | r = DB.get_settings() 13 | if len(r) < 1: 14 | self.set_defaults() 15 | for k in r.keys(): 16 | self.set(k, r[k]) 17 | 18 | def set(self, k, v, store_to_db=True): 19 | if store_to_db: 20 | DB.upd_setting(k, v) 21 | self.items[k] = v 22 | 23 | def get(self, k): 24 | if k in self.items: 25 | return self.items[k] 26 | return None 27 | 28 | def get_list(self, l:list[str]): 29 | out = {} 30 | for item in l: 31 | out[item] = self.get(item) 32 | return out 33 | 34 | 35 | def set_defaults(self): 36 | DB.upd_settings_by_keys(default_settings) 37 | DB.add_default_themes(themes) 38 | self.set_from_db() 39 | 40 | 41 | SETTINGS = BijaSettings() 42 | SETTINGS.set_from_db() 43 | -------------------------------------------------------------------------------- /bija/templates/quote.form.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {%- set p=profile -%} 4 | {%- include 'profile/profile.image.html' -%} 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | {{'emoji'| svg_icon('icon-lg emojis')|safe}} 13 |
14 |
15 |
16 | 17 |
18 |
19 |
20 | {%- set p=item -%} 21 | {%- include 'profile/profile.image.html' -%} 22 |
23 |
24 | {%- set note=item -%} 25 | {%- set reply_chain={'root':'','reply':''} -%} 26 | {%- include 'note.html' -%} 27 |
28 |
-------------------------------------------------------------------------------- /bija/templates/svg/emoji.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/svg/expand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 BrightonBTC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bija/templates/ln.invoice.html: -------------------------------------------------------------------------------- 1 |
2 |

{{'lightning'| svg_icon('icon-lg lightning')|safe}} Lightning invoice

3 |

Amount: {{ data['sats'] }} sats

4 | {%- if data['description']| length > 0 -%} 5 |

Description:
{{- data['description'] -}}

6 | {%- endif -%} 7 | {%- if data['date']| length > 0 -%} 8 |

Created: {{- data['date']|dt -}}

9 | {%- endif -%} 10 | {%- if data['expires']| length > 0 -%} 11 |

Expires: {{- data['expires']|dt -}}

12 | {%- endif -%} 13 |
14 |
15 | {{- data['qr']|safe -}} 16 |
{{ data['lnurl'] }}
17 | 18 |
19 |
-------------------------------------------------------------------------------- /bija/templates/list.members.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | {%- for profile in profiles: -%} 5 | 6 | 9 | 21 | 22 | {%- endfor -%} 23 |
7 | 8 | 10 |
11 |
12 | {%- set p=profile -%} 13 | {%- include 'profile/profile.image.html' -%} 14 |
15 |
16 | {{profile['name'] | ident_string(profile['display_name'], profile['public_key']) | safe }} 17 |
{{ p['public_key'] | relationship | safe }} 18 |
19 |
20 |
24 | 25 | 26 |
27 | -------------------------------------------------------------------------------- /bija/templates/profile/profile.brief.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | {%- set p=profile -%} 7 | {%- include 'profile/profile.image.html' -%} 8 |
9 |
10 | {{profile['name'] | ident_string(profile['display_name'], profile['public_key']) | safe }} 11 |
{{ p['public_key'] | relationship | safe }}
12 | {%- if page_id != "blocked" -%} 13 | {%- if profile['following'] == 1: -%} 14 | 15 | {%- else: -%} 16 | 17 | {%- endif -%} 18 | 19 | {%- else: -%} 20 | [ BLOCKED ] 21 | {%- endif -%} 22 |
23 |
24 | -------------------------------------------------------------------------------- /bija/password.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet, InvalidToken 2 | from cryptography.hazmat.backends import default_backend 3 | from cryptography.hazmat.primitives import hashes 4 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 5 | import base64 6 | 7 | salt = b"z9ZNrHLedGvBmxfu_XsGEqwx1ZP2LIYEeaxnj6A" 8 | 9 | 10 | def encrypt_key(password, to_encrypt): 11 | to_encrypt = to_encrypt.encode() 12 | 13 | kdf = PBKDF2HMAC( 14 | algorithm=hashes.SHA256(), 15 | length=32, 16 | salt=salt, 17 | iterations=100000, 18 | backend=default_backend() 19 | ) 20 | _key = base64.urlsafe_b64encode(kdf.derive(password.encode())) 21 | 22 | f = Fernet(_key) 23 | encrypted_string = f.encrypt(to_encrypt) 24 | return encrypted_string.decode() 25 | 26 | 27 | def decrypt_key(password, to_decrypt): 28 | kdf = PBKDF2HMAC( 29 | algorithm=hashes.SHA256(), 30 | length=32, 31 | salt=salt, 32 | iterations=100000, 33 | backend=default_backend() 34 | ) 35 | _key = base64.urlsafe_b64encode(kdf.derive(password.encode())) 36 | 37 | f = Fernet(_key) 38 | try: 39 | pw = f.decrypt(to_decrypt) 40 | return pw.decode() 41 | except InvalidToken: 42 | return False 43 | -------------------------------------------------------------------------------- /bija/static/qr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/deferred_tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from queue import Queue 3 | from threading import Lock 4 | 5 | from bija.args import LOGGING_LEVEL 6 | from bija.task_kinds import TaskKind 7 | 8 | logger = logging.getLogger(__name__) 9 | logger.setLevel(LOGGING_LEVEL) 10 | 11 | 12 | class Task: 13 | def __init__(self, kind: TaskKind, data: object) -> None: 14 | logger.info('TASK kind: {}'.format(kind)) 15 | self.kind = kind 16 | self.data = data 17 | 18 | 19 | class TaskPool: 20 | def __init__(self) -> None: 21 | logger.info('START TASK POOL') 22 | self.tasks: Queue[Task] = Queue() 23 | self.lock: Lock = Lock() 24 | 25 | def add(self, kind: TaskKind, data: object): 26 | logger.info('ADD task') 27 | self.tasks.put(Task(kind, data)) 28 | 29 | def get(self): 30 | logger.info('GET task') 31 | return self.tasks.get() 32 | 33 | def has_tasks(self): 34 | return self.tasks.qsize() > 0 35 | 36 | 37 | class DeferredTasks: 38 | 39 | def __init__(self) -> None: 40 | logger.info('DEFERRED TASKS') 41 | self.pool = TaskPool() 42 | 43 | def next(self) -> Task | None: 44 | if self.pool.has_tasks(): 45 | logger.info('NEXT task') 46 | return self.pool.get() 47 | return None 48 | 49 | -------------------------------------------------------------------------------- /bija/ws/pow.py: -------------------------------------------------------------------------------- 1 | import time 2 | from bija.ws.event import Event 3 | 4 | def zero_bits(b: int) -> int: 5 | n = 0 6 | 7 | if b == 0: 8 | return 8 9 | 10 | while b >> 1: 11 | b = b >> 1 12 | n += 1 13 | 14 | return 7 - n 15 | 16 | def count_leading_zero_bits(event_id: str) -> int: 17 | total = 0 18 | for i in range(0, len(event_id) - 2, 2): 19 | bits = zero_bits(int(event_id[i:i+2], 16)) 20 | total += bits 21 | 22 | if bits != 8: 23 | break 24 | 25 | return total 26 | 27 | def mine_event(content: str, difficulty: int, public_key: str, kind: int, tags: list=[]) -> Event: 28 | all_tags = [["nonce", "1", str(difficulty)]] 29 | all_tags.extend(tags) 30 | 31 | created_at = int(time.time()) 32 | event_id = Event.compute_id(public_key, created_at, kind, all_tags, content) 33 | num_leading_zero_bits = count_leading_zero_bits(event_id) 34 | 35 | attempts = 1 36 | while num_leading_zero_bits < difficulty: 37 | attempts += 1 38 | all_tags[0][1] = str(attempts) 39 | created_at = int(time.time()) 40 | event_id = Event.compute_id(public_key, created_at, kind, all_tags, content) 41 | num_leading_zero_bits = count_leading_zero_bits(event_id) 42 | 43 | return Event(public_key, content, created_at, kind, all_tags, event_id) 44 | -------------------------------------------------------------------------------- /bija/templates/message_thread.items.html: -------------------------------------------------------------------------------- 1 | {%- for message in messages: -%} 2 | 3 | {%- if message['name'] is not none -%} 4 | {%- set name=message.name -%} 5 | {%- else -%} 6 | {%- set name=(message.public_key | truncate(21, False, '...')) -%} 7 | {%- endif -%} 8 | 9 | {%- if message.is_sender -%} 10 | {%- if message.pic is not none and message.pic|length > 0 -%} 11 | {%- set pic=message.pic -%} 12 | {%- else -%} 13 | {%- set pic="/identicon?id="+message.public_key -%} 14 | {%- endif -%} 15 | {%- set sender=message.name -%} 16 | {%- set class='them' -%} 17 | {%- else -%} 18 | {%- if me.pic is not none -%} 19 | {%- set pic=me.pic -%} 20 | {%- else -%} 21 | {%- set pic="/identicon?id="+me.public_key -%} 22 | {%- endif -%} 23 | {%- set sender="You" -%} 24 | {%- set class='me' -%} 25 | {%- endif -%} 26 | 27 |
28 |
29 |
30 |
31 | {%- set content=message.content|decr(message.public_key, privkey) -%} 32 |
{{content|process_note_content|safe}}
33 |
34 |

{{message['created_at']|dt}}

35 |
36 | 37 |
38 | 39 | {%- endfor -%} -------------------------------------------------------------------------------- /bija/templates/profile/profile.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | {%- include 'profile/profile.header.html' -%} 8 | {%- if profile['blocked'] -%} 9 |

[ BLOCKED ]

10 | {%- endif -%} 11 |

Public posts

12 |
13 |
14 | {%- include 'feed/feed.items.html' -%} 15 |
16 |
17 |
18 | 19 | 26 |
27 |
28 |
Found new notes
29 |
30 |
31 | 32 | {%- endblock content -%} 33 | 34 | {%- block right_content -%} 35 | {%- if is_me: -%} 36 | {%- include 'note.form.html' -%} 37 | {%- endif -%} 38 | {%- endblock right_content -%} 39 | -------------------------------------------------------------------------------- /bija/templates/boosts.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 |

Boosts and quotes

8 |
9 | {%- for item in notes: -%} 10 | {%- if item is string -%} 11 |
12 |
13 |
Event: {{item}} not yet seen on network
14 |
15 | {%- else -%} 16 | {%- if item['deleted'] is none -%} 17 |
18 |
19 |
20 | {{item['nip05'] | nip05_valid(item['nip05_validated']) | safe }} 21 | {%- set p=item -%} 22 | {%- include 'profile/profile.image.html' -%} 23 |
24 |
25 | {%- set reply_chain = item['thread_root'] | get_thread_root(item['response_to'], item['id']) -%} 26 | {%- set note=item -%} 27 | {%- include 'note.html' -%} 28 |
29 |
30 | {%- else -%} 31 | {%- include 'deleted.note.html' -%} 32 | {%- endif -%} 33 | {%- endif -%} 34 | {%- endfor -%} 35 |
36 | {%- endblock content -%} 37 | 38 | 39 | -------------------------------------------------------------------------------- /bija/ws/relay_manager.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from bija.ws.filter import Filters 3 | from bija.ws.message_pool import MessagePool 4 | from bija.ws.relay import Relay, RelayPolicy 5 | 6 | class RelayManager: 7 | def __init__(self) -> None: 8 | self.relays: dict[str, Relay] = {} 9 | self.message_pool = MessagePool() 10 | 11 | def add_relay(self, url: str, read: bool=True, write: bool=True, subscriptions={}): 12 | policy = RelayPolicy(read, write) 13 | relay = Relay(url, policy, self.message_pool, subscriptions) 14 | self.relays[url] = relay 15 | 16 | def remove_relay(self, url: str): 17 | self.relays.pop(url) 18 | 19 | def add_subscription(self, id: str, filters: Filters, batch=0): 20 | for relay in self.relays.values(): 21 | relay.add_subscription(id, filters, batch) 22 | 23 | def close_subscription(self, id: str): 24 | for relay in self.relays.values(): 25 | relay.close_subscription(id) 26 | 27 | def open_connections(self, ssl_options: dict=None): 28 | for relay in self.relays.values(): 29 | threading.Thread( 30 | target=relay.connect, 31 | args=(ssl_options,), 32 | name=f"{relay.url}-thread" 33 | ).start() 34 | 35 | def close_connections(self): 36 | for relay in self.relays.values(): 37 | relay.close() 38 | 39 | def publish_message(self, message: str): 40 | for relay in self.relays.values(): 41 | if relay.policy.should_write: 42 | relay.publish(message) 43 | 44 | def get_connection_status(self): 45 | out = [] 46 | for relay in self.relays.values(): 47 | out.append([relay.url, relay.active]) 48 | return out 49 | 50 | 51 | -------------------------------------------------------------------------------- /bija/templates/svg/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bija/templates/relays.list.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {%- for relay in relays: -%} 12 | 13 | 14 | 21 | 28 | 35 | 36 | 37 | {%- endfor -%} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
RelayReadWritePreferredRemove
{{relay.name}} 15 | {%- if relay.receive -%} 16 | 17 | {%- else -%} 18 | 19 | {%- endif -%} 20 | 22 | {%- if relay.send -%} 23 | 24 | {%- else -%} 25 | 26 | {%- endif -%} 27 | 29 | {%- if relay.fav -%} 30 | 31 | {%- else -%} 32 | 33 | {%- endif -%} 34 |
46 |
47 | -------------------------------------------------------------------------------- /bija/templates/note.reply_form.html: -------------------------------------------------------------------------------- 1 | {%- if note['liked'] -%} 2 | {%- set liked_im = 'liked' -%} 3 | {%- else -%} 4 | {%- set liked_im = 'like' -%} 5 | {%- endif -%} 6 |

7 | 8 | {{'reply'| svg_icon('icon')|safe}} 9 | {{note['replies'] if note['replies']}} 10 | 11 | 12 | 19 | {{note['shares'] if note['shares']}} 20 | 21 | 22 | 23 | 24 | 25 |

26 |
27 | 28 | 29 | 30 | 31 |
32 |
33 | 34 | {{'emoji'| svg_icon('icon-lg emojis')|safe}} 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /bija/templates/note.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | 5 | {{note['name'] | ident_string(note['display_name'], note['public_key']) | safe }} 6 | {%- if note['following'] is not none and note['following']==1 -%} 7 | {{'following'| svg_icon('icon')|safe}} 8 | {%- endif -%} 9 | 10 | 11 | 12 | {{note['created_at']|dt}} 13 | 14 | 15 | {{'note-tools'| svg_icon('icon')|safe}} 16 |
    17 | {%- if note['public_key'] == pubkey -%} 18 |
  • delete
  • 19 | {%- endif -%} 20 |
  • share
  • 21 |
  • add to list
  • 22 |
  • info
  • 23 |
  • block
  • 24 |
25 |
26 |
27 |

28 |
29 |
{{note['content'] | process_note_content |safe}}
30 |
{{note['media'] | process_media_attachments | safe}}
31 | {%- if note['reshare'] is not none -%} 32 | {%- if note['reshare'] is string -%} 33 |
34 | {%- else -%} 35 | {%- with item = note['reshare'], is_reshare = true -%} 36 | {%- include 'thread.item.html' -%} 37 | {%- endwith -%} 38 | {%- endif -%} 39 | {%- endif -%} 40 |
41 | {%- if not is_reshare -%} 42 | {%- include 'note.reply_form.html' -%} 43 | {%- endif -%} 44 |
-------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Please note that this project is no longer being maintained: 2 | 3 | I'm still working on some Nostr based projects but made the decision to end development on this. Many other Twitter style apps exist on Nostr now so please choose one of those instead. Thanks. 4 | 5 | # Bija Nostr Client 6 | 7 | Python [Nostr](https://github.com/nostr-protocol/nostr) client with backend that runs on a local flask server, and front end in your browser 8 | 9 | *nb. earlier versions of Bija opened a Qt window. That's not currently available, you can only load the UI in a browser at this time.* 10 | 11 | This is experimental software in early development and comes without warranty. 12 | 13 | ### Native Setup :snake: 14 | 15 | If you want to give it a test run you can find early releases for Linux (Windows and OSX versions intended at a later date) on the [releases page](https://github.com/BrightonBTC/bija/releases) 16 | 17 | Or, to get it up and running yourself: 18 | 19 | ``` 20 | git clone https://github.com/BrightonBTC/bija 21 | cd bija 22 | pip install -r ./requirements.txt 23 | python3 cli.py 24 | ``` 25 | *nb. requires python3.10+* 26 | 27 | You can now access bija at http://localhost:5000 28 | 29 | In the event that something else is running on port 5000 you can pass `--port XXXX` to cli.py and if you want to use/create a different db, for example if you want to manage multiple accounts then use `--db name` (default is called bija) 30 | 31 | eg. 32 | 33 | ``` 34 | python3 cli.py --port 5001 --db mydb 35 | ``` 36 | Or additionally to the above you could compile using pyinstaller: 37 | * This should theoretically also work for OSX but is untested (please let me know if you have success!). Bija currently has some dependencies that are incompatible with Windows though. 38 | ``` 39 | pyinstaller cli.py --onefile -w -F --add-data "bija/templates:bija/templates" --add-data "bija/static:bija/static" --name "bija-nostr-client" 40 | 41 | ``` 42 | ### Docker Setup :whale2: 43 | 44 | To setup Bija with docker, first clone the project: 45 | ``` 46 | git clone https://github.com/BrightonBTC/bija 47 | cd bija 48 | ``` 49 | 50 | Then just run docker-compose up and access Bija through the browser 51 | 52 | ``` 53 | docker-compose up 54 | ``` 55 | 56 | You can now access bija at http://localhost:5000 57 | 58 | Enjoy :grinning: 59 | -------------------------------------------------------------------------------- /bija/templates/messages.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 | {%- if inbox -%} 8 |

{{'messages'| svg_icon('icon-lg')|safe}} Inbox

9 | {%- else -%} 10 |

{{'messages'| svg_icon('icon-lg')|safe}} Junk

11 | {%- endif -%} 12 |
13 | {%- for message in messages: -%} 14 | 15 | 16 | {%- if message.is_sender==1 -%} 17 | {%- set sender="right-arrow"| svg_icon('icon-sm') -%} 18 | {%- else -%} 19 | {%- set sender="left-arrow"| svg_icon('icon-sm') -%} 20 | {%- endif -%} 21 | 22 | 38 | 39 | {%- endfor -%} 40 |
41 | 42 | 49 |
50 |
51 |
Found new notes
52 |
53 |
54 | {%- endblock content -%} 55 | 56 | {%- block right_content -%} 57 |
58 | 62 |
63 | {%- endblock right_content -%} -------------------------------------------------------------------------------- /bija/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | DEFAULT_RELAYS = [ 4 | 'wss://nostr.drss.io', 5 | 'wss://nostr-pub.wellorder.net', 6 | 'wss://relay.damus.io', 7 | 'wss://sg.qemura.xyz', 8 | 'wss://brb.io', 9 | 'wss://relay.nostrati.com', 10 | 'wss://pow.nostrati.com', 11 | 'wss://relay.snort.social', 12 | 'wss://eden.nostr.land', 13 | 'wss://nostr.zebedee.cloud', 14 | 'wss://offchain.pub' 15 | ] 16 | 17 | default_settings = { 18 | 'pow_default': '16', 19 | 'pow_default_enc': '16', 20 | 'pow_required': '16', 21 | 'pow_required_enc': '16', 22 | 'theme': 'default', 23 | 'spacing': '8', 24 | 'fs-base': '16', 25 | 'rnd': '5', 26 | 'icon': '14', 27 | 'pfp-dim': '40', 28 | 'recent_emojis': '[]' 29 | } 30 | 31 | themes = { 32 | 'default': { 33 | 'txt-clr': '#fdffee' 34 | }, 35 | 'dark1': { 36 | 'txt-clr': '#dee892', 37 | 'txt-clr2': '#56bc6c', 38 | 'lnk-clr': '#36cfe8', 39 | 'lnk-clr-hvr': '#03d4f6', 40 | 'lnk-clr2': '#b25eb8', 41 | 'bg-clr1': '#121a3a', 42 | 'bg-clr1-fade': '#0c1228', 43 | 'bg-clr2': '#10162e', 44 | 'bg-clr3': '#030324', 45 | 'bdr-clr-1': '#00002b', 46 | 'bdr-clr-2': '#524186' 47 | }, 48 | 'dark2': { 49 | 'txt-clr': '#cefff1', 50 | 'txt-clr2': '#56bc6c', 51 | 'lnk-clr': '#a0f69e', 52 | 'lnk-clr-hvr': '#4ec74c', 53 | 'lnk-clr2': '#c46868', 54 | 'bg-clr1': '#000000', 55 | 'bg-clr1-fade': '#221d1d', 56 | 'bg-clr2': '#000000', 57 | 'bg-clr3': '#0c1722', 58 | 'bdr-clr-1': '#4c3434', 59 | 'bdr-clr-2': '#503231', 60 | 'mnu-clr': '#ffffff', 61 | 'mnu-clr-hvr': '#ff7b66', 62 | 'nav-icon-clr': '#5d5e62' 63 | }, 64 | "light1":{ 65 | "txt-clr": "#1a3f8c", 66 | "txt-clr2": "#56bc6c", 67 | "lnk-clr": "#1df26e", 68 | "lnk-clr-hvr": "#00e055", 69 | "lnk-clr2": "#b25eb8", 70 | "bg-clr1": "#f6f7f8", 71 | "bg-clr1-fade": "#ececf2", 72 | "bg-clr2": "#f0f4fa", 73 | "bg-clr3": "#e7e8f8", 74 | "bdr-clr-1": "#e0e6df", 75 | "bdr-clr-2": "#e1ddec", 76 | "mnu-clr": "#93e088", 77 | "mnu-clr-hvr": "#2d2db8", 78 | "input-bg-clr": "#f9fef8", 79 | "input-clr": "#3b8dd2", 80 | "input-bdr-clr": "#4334f0", 81 | "btn-bg": "#55a07d", 82 | "btn-bg-hvr": "green", 83 | "nav-icon-clr": "#a6a5d4", 84 | "icon-clr": "#3b8dd2" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /bija/templates/thread.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 |

Thread:

8 |
9 | {%- if root is not none -%} 10 | {%- if root is string -%} 11 | {%- with id=root -%} 12 | {%- include 'thread.placeholder.html' -%} 13 | {%- endwith -%} 14 | {%- else -%} 15 | {%- with item=root -%} 16 | {%- include 'thread.item.html' -%} 17 | {%- endwith -%} 18 | {%- endif -%} 19 | {%- endif -%} 20 | 21 | {%- if parent is not none -%} 22 | {%- if parent is string -%} 23 | {%- with id=parent -%} 24 | {%- include 'thread.placeholder.html' -%} 25 | {%- endwith -%} 26 | {%- else -%} 27 | {%- if parent['response_to'] is not none -%} 28 |
29 |
30 |
31 | {{'up'| svg_icon('icon-sm')|safe}} Load more 32 |
33 | {%- endif -%} 34 | {%- with item=parent -%} 35 | {%- include 'thread.item.html' -%} 36 | {%- endwith -%} 37 | {%- endif -%} 38 | {%- endif -%} 39 | 40 | {%- if note is not none -%} 41 | {%- if note is string -%} 42 | {%- with id=note -%} 43 | {%- include 'thread.placeholder.html' -%} 44 | {%- endwith -%} 45 | {%- else -%} 46 | {%- with item=note -%} 47 | {%- include 'thread.item.html' -%} 48 | {%- endwith -%} 49 | {%- endif -%} 50 | {%- endif -%} 51 | 52 | {%- for item in replies: -%} 53 | {%- if item is string -%} 54 | {%- with id=item -%} 55 | {%- include 'thread.placeholder.html' -%} 56 | {%- endwith -%} 57 | {%- else -%} 58 | {%- include 'thread.item.html' -%} 59 | {%- endif -%} 60 | 61 | {%- endfor -%} 62 |
63 | 64 | 65 | 66 | {%- endblock content -%} 67 | 68 | 69 | {%- block right_content -%} 70 |

in this thread

71 |
72 | 80 |
81 | 82 | {%- endblock right_content -%} -------------------------------------------------------------------------------- /bija/ws/event.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | from enum import IntEnum 4 | from secp256k1 import PrivateKey, PublicKey 5 | from hashlib import sha256 6 | 7 | class EventKind(IntEnum): 8 | SET_METADATA = 0 9 | TEXT_NOTE = 1 10 | RECOMMEND_RELAY = 2 11 | CONTACTS = 3 12 | ENCRYPTED_DIRECT_MESSAGE = 4 13 | DELETE = 5 14 | BOOST = 6 15 | REACTION = 7 16 | BLOCK_LIST = 10000 17 | RELAY_LIST = 10002 18 | PERSON_LIST = 30000 19 | BOOKMARK_LIST = 30001 20 | 21 | class Event(): 22 | def __init__( 23 | self, 24 | public_key: str, 25 | content: str, 26 | created_at: int=int(time.time()), 27 | kind: int=EventKind.TEXT_NOTE, 28 | tags: "list[list[str]]"=[], 29 | id: str=None, 30 | signature: str=None) -> None: 31 | if not isinstance(content, str): 32 | raise TypeError("Argument 'content' must be of type str") 33 | 34 | self.public_key = public_key 35 | self.content = content 36 | self.created_at = created_at or int(time.time()) 37 | self.kind = kind 38 | self.tags = tags 39 | self.signature = signature 40 | self.id = id or Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content) 41 | 42 | @staticmethod 43 | def serialize(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> bytes: 44 | data = [0, public_key, created_at, kind, tags, content] 45 | data_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) 46 | return data_str.encode() 47 | 48 | @staticmethod 49 | def compute_id(public_key: str, created_at: int, kind: int, tags: "list[list[str]]", content: str) -> str: 50 | return sha256(Event.serialize(public_key, created_at, kind, tags, content)).hexdigest() 51 | 52 | def sign(self, private_key_hex: str) -> None: 53 | sk = PrivateKey(bytes.fromhex(private_key_hex)) 54 | sig = sk.schnorr_sign(bytes.fromhex(self.id), None, raw=True) 55 | self.signature = sig.hex() 56 | 57 | def verify(self) -> bool: 58 | pub_key = PublicKey(bytes.fromhex("02" + self.public_key), True) # add 02 for schnorr (bip340) 59 | event_id = Event.compute_id(self.public_key, self.created_at, self.kind, self.tags, self.content) 60 | return pub_key.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(self.signature), None, raw=True) 61 | 62 | def to_json_object(self) -> dict: 63 | return { 64 | "id": self.id, 65 | "pubkey": self.public_key, 66 | "created_at": self.created_at, 67 | "kind": self.kind, 68 | "tags": self.tags, 69 | "content": self.content, 70 | "sig": self.signature 71 | } 72 | -------------------------------------------------------------------------------- /bija/setup.py: -------------------------------------------------------------------------------- 1 | from bip39 import bip39 2 | 3 | from bija.helpers import is_hex_key, is_bech32_key, bech32_to_hex64, hex64_to_bech32 4 | from bija.ws.key import PrivateKey 5 | 6 | 7 | PK = None 8 | PW = None 9 | 10 | class bcolors: 11 | OKGREEN = '\033[92m' 12 | OKBLUE = '\033[94m' 13 | OKCYAN = '\033[96m' 14 | FAIL = '\033[91m' 15 | ENDC = '\033[0m' 16 | 17 | 18 | def setup(): 19 | global PK, PW 20 | complete = False 21 | step = 1 22 | while not complete: 23 | if step == 1: 24 | print('Enter your private key or type "new" to create a new one') 25 | pk = input('Private key:') 26 | if pk.lower().strip() == 'new': 27 | step = 2 28 | elif is_hex_key(pk.strip()): 29 | step = 2 30 | PK = pk.strip() 31 | elif is_bech32_key('nsec', pk.strip()): 32 | step = 2 33 | PK = bech32_to_hex64('nsec', pk.strip()) 34 | else: 35 | print(f"{bcolors.FAIL}That doesn\'t seem to be a valid key, use hex or nsec{bcolors.ENDC}") 36 | if step == 2: 37 | print('Enter a password. This will encrypt your stored private key and be required for login.') 38 | pw = input('Password:') 39 | if len(pw) > 0: 40 | print('Password created. you can use it directly when starting bija with the flag --pw') 41 | PW = pw.strip() 42 | step = 3 43 | if step == 3: 44 | print('done') 45 | if PK is None: 46 | pk = PrivateKey() 47 | else: 48 | pk = PrivateKey(bytes.fromhex(PK)) 49 | PK = pk.hex() 50 | public_key = pk.public_key.hex() 51 | 52 | print('-----------------') 53 | print("Setup complete. Please backup your keys. Both hex and bech 32 encodings are provided:") 54 | print("If your not sure which to use then we recommend backing them both up") 55 | print(f"{bcolors.OKGREEN}Share your PUBLIC key with friends{bcolors.ENDC}") 56 | print(f"{bcolors.OKGREEN}Never share your PRIVATE key with anyone. Keep it safe{bcolors.ENDC}") 57 | print('-----------------') 58 | print(f"{bcolors.OKGREEN}Private key:{bcolors.ENDC}") 59 | 60 | print(f"{bcolors.OKBLUE}HEX{bcolors.ENDC} ", PK) 61 | print(f"{bcolors.OKBLUE}Bech32{bcolors.ENDC} ", hex64_to_bech32('nsec', PK)) 62 | print('-----------------') 63 | print(f"{bcolors.OKGREEN}Public key:{bcolors.ENDC}") 64 | print(f"{bcolors.OKBLUE}HEX{bcolors.ENDC} ", public_key) 65 | print(f"{bcolors.OKBLUE}Bech32{bcolors.ENDC} ", hex64_to_bech32('npub', public_key)) 66 | print('-----------------') 67 | 68 | finish = input("I've backed up my keys. Type (y) to continue.") 69 | if finish.lower().strip() == 'y': 70 | complete = True 71 | 72 | return PK, PW 73 | -------------------------------------------------------------------------------- /bija/search.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from flask import request 5 | 6 | from bija.app import app 7 | from bija.args import LOGGING_LEVEL 8 | from bija.db import BijaDB 9 | from bija.helpers import is_hex_key, is_bech32_key, is_nip05, bech32_to_hex64 10 | from bija.nip5 import Nip5 11 | 12 | DB = BijaDB(app.session) 13 | logger = logging.getLogger(__name__) 14 | logger.setLevel(LOGGING_LEVEL) 15 | 16 | 17 | class Search: 18 | 19 | def __init__(self): 20 | logger.info('SEARCH') 21 | self.term = None 22 | self.message = None 23 | self.results = None 24 | self.redirect = None 25 | self.action = None 26 | 27 | self.process() 28 | 29 | def process(self): 30 | if 'search_term' in request.args or len(request.args['search_term'].strip()) < 1: 31 | self.term = request.args['search_term'] 32 | if self.term[:1] == '#': 33 | self.by_hash() 34 | elif self.term[:1] == '@': 35 | self.by_at() 36 | elif is_hex_key(self.term): 37 | self.by_hex() 38 | elif is_bech32_key('npub', self.term): 39 | self.by_npub() 40 | elif is_bech32_key('note', self.term): 41 | self.by_note() 42 | elif is_nip05(self.term): 43 | self.by_nip05() 44 | else: 45 | self.message = "no search term found!" 46 | 47 | def by_hash(self): 48 | # self.message = 'Searching network for {}'.format(self.term) 49 | # self.results = DB.get_search_feed(int(time.time()), self.term) 50 | # self.action = 'hash' 51 | self.redirect = '/topic?tag={}'.format(self.term[1:]) 52 | 53 | def by_at(self): 54 | pk = DB.get_profile_by_name_or_pk(self.term[1:]) 55 | if pk is not None: 56 | self.redirect = '/profile?pk={}'.format(pk.public_key) 57 | 58 | def by_hex(self): 59 | self.redirect = '/profile?pk={}'.format(self.term) 60 | 61 | def by_npub(self): 62 | b_key = bech32_to_hex64('npub', self.term) 63 | if b_key: 64 | self.redirect = '/profile?pk={}'.format(b_key) 65 | else: 66 | self.message = 'invalid npub' 67 | 68 | def by_note(self): 69 | b_key = bech32_to_hex64('note', self.term) 70 | if b_key: 71 | self.redirect = '/note?id={}'.format(b_key) 72 | else: 73 | self.message = 'invalid note' 74 | 75 | def by_nip05(self): 76 | profile = DB.get_pk_by_nip05(self.term) 77 | if profile is not None: 78 | self.redirect = '/profile?pk={}'.format(profile.public_key) 79 | else: 80 | nip5 = Nip5(self.term) 81 | if nip5.pk is not None: 82 | self.redirect = '/profile?pk={}'.format(nip5.pk) 83 | else: 84 | self.message = "Nip-05 identifier could not be located" 85 | 86 | def get(self): 87 | return self.results, self.redirect, self.message, self.action 88 | 89 | -------------------------------------------------------------------------------- /bija/ws/filter.py: -------------------------------------------------------------------------------- 1 | from collections import UserList 2 | from bija.ws.event import Event 3 | 4 | class Filter: 5 | def __init__( 6 | self, 7 | ids: "list[str]"=None, 8 | kinds: "list[int]"=None, 9 | authors: "list[str]"=None, 10 | since: int=None, 11 | until: int=None, 12 | tags: "dict[str, list[str]]"=None, 13 | limit: int=None) -> None: 14 | self.IDs = ids 15 | self.kinds = kinds 16 | self.authors = authors 17 | self.since = since 18 | self.until = until 19 | self.tags = tags 20 | self.limit = limit 21 | 22 | def matches(self, event: Event) -> bool: 23 | if self.IDs is not None and event.id not in self.IDs: 24 | return False 25 | if self.kinds is not None and event.kind not in self.kinds: 26 | return False 27 | if self.authors is not None and event.public_key not in self.authors: 28 | return False 29 | if self.since is not None and event.created_at < self.since: 30 | return False 31 | if self.until is not None and event.created_at > self.until: 32 | return False 33 | if self.tags is not None and len(event.tags) == 0: 34 | return False 35 | if self.tags is not None: 36 | e_tag_identifiers = [e_tag[0] for e_tag in event.tags] 37 | for f_tag, f_tag_values in self.tags.items(): 38 | if f_tag[1:] not in e_tag_identifiers: 39 | return False 40 | match_found = False 41 | for e_tag in event.tags: 42 | if e_tag[0] == f_tag[1:] and e_tag[1] in f_tag_values: 43 | match_found = True 44 | break 45 | if not match_found: 46 | return False 47 | return True 48 | 49 | def to_json_object(self) -> dict: 50 | res = {} 51 | if self.IDs is not None: 52 | res["ids"] = self.IDs 53 | if self.kinds is not None: 54 | res["kinds"] = self.kinds 55 | if self.authors is not None: 56 | res["authors"] = self.authors 57 | if self.since is not None: 58 | res["since"] = self.since 59 | if self.until is not None: 60 | res["until"] = self.until 61 | if self.tags is not None: 62 | for tag, values in self.tags.items(): 63 | res[tag] = values 64 | if self.limit is not None: 65 | res["limit"] = self.limit 66 | 67 | return res 68 | 69 | class Filters(UserList): 70 | def __init__(self, initlist: "list[Filter]"=[]) -> None: 71 | super().__init__(initlist) 72 | self.data: "list[Filter]" 73 | 74 | def match(self, event: Event): 75 | for filter in self.data: 76 | if filter.matches(event): 77 | return True 78 | return False 79 | 80 | def to_json_array(self) -> list: 81 | return [filter.to_json_object() for filter in self.data] 82 | -------------------------------------------------------------------------------- /bija/nip5.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | import requests 5 | 6 | from bija.app import app 7 | from bija.args import LOGGING_LEVEL 8 | from bija.db import BijaDB 9 | from bija.helpers import is_nip05, is_bech32_key, bech32_to_hex64 10 | 11 | DB = BijaDB(app.session) 12 | logger = logging.getLogger(__name__) 13 | logger.setLevel(LOGGING_LEVEL) 14 | 15 | class Nip5: 16 | 17 | def __init__(self, nip5): 18 | self.nip5 = nip5 19 | self.name = None 20 | self.address = None 21 | self.pk = None 22 | self.response = None 23 | 24 | self.fetch() 25 | if self.response is not None: 26 | self.process() 27 | 28 | def is_valid_format(self): 29 | parts = is_nip05(self.nip5) 30 | if parts: 31 | self.name = parts[0] 32 | self.address = parts[1] 33 | return True 34 | return False 35 | 36 | def match(self, pk): 37 | if self.pk is not None and self.pk == pk: 38 | return True 39 | elif self.pk is not None and is_bech32_key('npub', self.pk): # we shouldn't need this as it's not valid to use bech32 but some services are currently using it 40 | if bech32_to_hex64('npub', self.pk) == pk: 41 | return True 42 | return False 43 | 44 | def fetch(self): 45 | if self.is_valid_format(): 46 | try: 47 | url = 'https://{}/.well-known/nostr.json'.format(self.address) 48 | logger.info('request: {}'.format(url)) 49 | response = requests.get( 50 | url, params={'name': self.name}, timeout=2, headers={'User-Agent': 'Bija Nostr Client'} 51 | ) 52 | logger.info('response status: {}'.format(response.status_code)) 53 | if response.status_code == 200: 54 | self.response = response 55 | except requests.exceptions.HTTPError as e: 56 | logger.error("Http Error: {}".format(e)) 57 | except requests.exceptions.ConnectionError as e: 58 | logger.error("Error Connecting:".format(e)) 59 | except requests.exceptions.Timeout as e: 60 | logger.error("Timeout Error:".format(e)) 61 | except requests.exceptions.RequestException as e: 62 | logger.error("OOps: Something Else".format(e)) 63 | 64 | 65 | def process(self): 66 | try: 67 | d = self.response.json() 68 | logger.info('response.json: {}'.format(d)) 69 | logger.info('search name: [{}]'.format(self.name)) 70 | if self.name in d['names']: 71 | logger.info('name found: {}'.format(self.name)) 72 | self.pk = d['names'][self.name] 73 | elif self.name.lower() in d['names']: 74 | logger.info('name found: {}'.format(self.name.lower())) 75 | self.pk = d['names'][self.name.lower()] 76 | except ValueError: 77 | logging.error(traceback.format_exc()) 78 | except Exception: 79 | logging.error(traceback.format_exc()) 80 | -------------------------------------------------------------------------------- /bija/ws/message_pool.py: -------------------------------------------------------------------------------- 1 | import json 2 | from queue import Queue 3 | from threading import Lock 4 | from bija.ws.message_type import RelayMessageType 5 | from bija.ws.event import Event 6 | 7 | class EventMessage: 8 | def __init__(self, event: Event, subscription_id: str, url: str) -> None: 9 | self.event = event 10 | self.subscription_id = subscription_id 11 | self.url = url 12 | 13 | class NoticeMessage: 14 | def __init__(self, content: str, url: str) -> None: 15 | self.content = content 16 | self.url = url 17 | 18 | class EndOfStoredEventsMessage: 19 | def __init__(self, subscription_id: str, url: str) -> None: 20 | self.subscription_id = subscription_id 21 | self.url = url 22 | 23 | class OkMessage: 24 | def __init__(self, content: str, url: str) -> None: 25 | self.content = content 26 | self.url = url 27 | 28 | 29 | class MessagePool: 30 | def __init__(self) -> None: 31 | self.events: Queue[EventMessage] = Queue() 32 | self.notices: Queue[NoticeMessage] = Queue() 33 | self.eose_notices: Queue[EndOfStoredEventsMessage] = Queue() 34 | self.ok_notices: Queue[OkMessage] = Queue() 35 | # self._unique_events: set = set() 36 | self.lock: Lock = Lock() 37 | 38 | def add_message(self, message: str, url: str): 39 | self._process_message(message, url) 40 | 41 | def get_event(self): 42 | return self.events.get() 43 | 44 | def get_notice(self): 45 | return self.notices.get() 46 | 47 | def get_eose_notice(self): 48 | return self.eose_notices.get() 49 | 50 | def get_ok_notice(self): 51 | return self.ok_notices.get() 52 | 53 | def has_events(self): 54 | return self.events.qsize() > 0 55 | 56 | def has_notices(self): 57 | return self.notices.qsize() > 0 58 | 59 | def has_eose_notices(self): 60 | return self.eose_notices.qsize() > 0 61 | 62 | def has_ok_notices(self): 63 | return self.ok_notices.qsize() > 0 64 | 65 | def _process_message(self, message: str, url: str): 66 | message_json = json.loads(message) 67 | message_type = message_json[0] 68 | if message_type == RelayMessageType.EVENT: 69 | subscription_id = message_json[1] 70 | e = message_json[2] 71 | event = Event(e['pubkey'], e['content'], e['created_at'], e['kind'], e['tags'], e['id'], e['sig']) 72 | self.events.put(EventMessage(event, subscription_id, url)) 73 | # with self.lock: 74 | # uid = subscription_id+event.id 75 | # if not uid in self._unique_events: 76 | # self.events.put(EventMessage(event, subscription_id, url)) 77 | # self._unique_events.add(uid) 78 | 79 | elif message_type == RelayMessageType.NOTICE: 80 | self.notices.put(NoticeMessage(message_json[1], url)) 81 | elif message_type == RelayMessageType.END_OF_STORED_EVENTS: 82 | self.eose_notices.put(EndOfStoredEventsMessage(message_json[1], url)) 83 | elif message_type == RelayMessageType.OK: 84 | self.ok_notices.put(OkMessage(message, url)) 85 | 86 | 87 | -------------------------------------------------------------------------------- /bija/ogtags.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | import urllib 5 | from urllib import request 6 | from urllib.error import HTTPError, URLError 7 | from urllib.request import Request 8 | 9 | import validators 10 | from bs4 import BeautifulSoup 11 | 12 | from bija.app import app 13 | from bija.args import LOGGING_LEVEL 14 | from bija.db import BijaDB 15 | from bija.helpers import request_url_head, timestamp_minus, TimePeriod 16 | from bija.settings import SETTINGS 17 | 18 | DB = BijaDB(app.session) 19 | logger = logging.getLogger(__name__) 20 | logger.setLevel(LOGGING_LEVEL) 21 | 22 | 23 | class OGTags: 24 | 25 | def __init__(self, data): 26 | logger.info('OG TAGS') 27 | self.note_id = data['note_id'] 28 | self.url = data['url'] 29 | self.og = {} 30 | self.note = DB.get_note(SETTINGS.get('pubkey'), self.note_id) 31 | self.response = None 32 | if self.should_fetch(): 33 | self.fetch() 34 | if self.response: 35 | self.process() 36 | self.insert_url() 37 | 38 | def should_fetch(self): 39 | db_entry = DB.get_url(self.url) 40 | if db_entry is not None and db_entry.ts > timestamp_minus(TimePeriod.WEEK): 41 | if db_entry.og is not None: 42 | self.update_note() 43 | return False 44 | else: 45 | return True 46 | 47 | def fetch(self): 48 | logger.info('fetch for {}'.format(self.url)) 49 | req = Request(self.url, headers={'User-Agent': 'Bija Nostr Client'}) 50 | h = request_url_head(self.url) 51 | if h and h.get('content-type'): 52 | if h.get('content-type').split(';')[0] == 'text/html': 53 | try: 54 | with urllib.request.urlopen(req, timeout=2) as response: 55 | if response.status == 200: 56 | self.response = response.read() 57 | except HTTPError as error: 58 | print(error.status, error.reason) 59 | except URLError as error: 60 | print(error.reason) 61 | except TimeoutError: 62 | print("Request timed out") 63 | 64 | def process(self): 65 | logger.info('process {}'.format(self.url)) 66 | if self.response is not None: 67 | soup = BeautifulSoup(self.response, 'html.parser') 68 | for prop in ['image', 'title', 'description', 'url']: 69 | item = soup.find("meta", property="og:{}".format(prop)) 70 | if item is not None: 71 | content = item.get("content") 72 | if content is not None: 73 | if prop in ['url', 'image']: 74 | if validators.url(content): 75 | self.og[prop] = content 76 | else: 77 | self.og[prop] = content 78 | 79 | if len(self.og) > 0: 80 | if 'url' not in self.og: 81 | self.og['url'] = self.url 82 | self.update_note() 83 | 84 | 85 | def update_note(self): 86 | logger.info('update note with url') 87 | DB.update_note_media(self.note_id, json.dumps([[self.url, 'website']])) 88 | 89 | def insert_url(self): 90 | logger.info('insert url and og data') 91 | og = None 92 | if len(self.og) > 0: 93 | og = json.dumps(self.og) 94 | DB.insert_url(self.url, int(time.time()), og) 95 | -------------------------------------------------------------------------------- /bija/ws/key.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import base64 3 | import secp256k1 4 | from cffi import FFI 5 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 6 | from cryptography.hazmat.primitives import padding 7 | from bija.ws import bech32 8 | 9 | 10 | class PublicKey: 11 | def __init__(self, raw_bytes: bytes) -> None: 12 | self.raw_bytes = raw_bytes 13 | 14 | def bech32(self) -> str: 15 | converted_bits = bech32.convertbits(self.raw_bytes, 8, 5) 16 | return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32) 17 | 18 | def hex(self) -> str: 19 | return self.raw_bytes.hex() 20 | 21 | def verify_signed_message_hash(self, hash: str, sig: str) -> bool: 22 | pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True) 23 | return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True) 24 | 25 | class PrivateKey: 26 | def __init__(self, raw_secret: bytes=None) -> None: 27 | if not raw_secret is None: 28 | self.raw_secret = raw_secret 29 | else: 30 | self.raw_secret = secrets.token_bytes(32) 31 | 32 | sk = secp256k1.PrivateKey(self.raw_secret) 33 | self.public_key = PublicKey(sk.pubkey.serialize()[1:]) 34 | 35 | @classmethod 36 | def from_nsec(cls, nsec: str): 37 | """ Load a PrivateKey from its bech32/nsec form """ 38 | hrp, data, spec = bech32.bech32_decode(nsec) 39 | raw_secret = bech32.convertbits(data, 5, 8)[:-1] 40 | return cls(bytes(raw_secret)) 41 | 42 | def bech32(self) -> str: 43 | converted_bits = bech32.convertbits(self.raw_secret, 8, 5) 44 | return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32) 45 | 46 | def hex(self) -> str: 47 | return self.raw_secret.hex() 48 | 49 | def tweak_add(self, scalar: bytes) -> bytes: 50 | sk = secp256k1.PrivateKey(self.raw_secret) 51 | return sk.tweak_add(scalar) 52 | 53 | def compute_shared_secret(self, public_key_hex: str) -> bytes: 54 | pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True) 55 | return pk.ecdh(self.raw_secret, hashfn=copy_x) 56 | 57 | def encrypt_message(self, message: str, public_key_hex: str) -> str: 58 | padder = padding.PKCS7(128).padder() 59 | padded_data = padder.update(message.encode()) + padder.finalize() 60 | 61 | iv = secrets.token_bytes(16) 62 | cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) 63 | 64 | encryptor = cipher.encryptor() 65 | encrypted_message = encryptor.update(padded_data) + encryptor.finalize() 66 | 67 | return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" 68 | 69 | def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str: 70 | encoded_data = encoded_message.split('?iv=') 71 | encoded_content, encoded_iv = encoded_data[0], encoded_data[1] 72 | 73 | iv = base64.b64decode(encoded_iv) 74 | cipher = Cipher(algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)) 75 | encrypted_content = base64.b64decode(encoded_content) 76 | 77 | decryptor = cipher.decryptor() 78 | decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() 79 | 80 | unpadder = padding.PKCS7(128).unpadder() 81 | unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() 82 | 83 | return unpadded_data.decode() 84 | 85 | def sign_message_hash(self, hash: bytes) -> str: 86 | sk = secp256k1.PrivateKey(self.raw_secret) 87 | sig = sk.schnorr_sign(hash, None, raw=True) 88 | return sig.hex() 89 | 90 | def __eq__(self, other): 91 | return self.raw_secret == other.raw_secret 92 | 93 | 94 | ffi = FFI() 95 | @ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)") 96 | def copy_x(output, x32, y32, data): 97 | ffi.memmove(output, x32, 32) 98 | return 1 -------------------------------------------------------------------------------- /bija/templates/profile/profile.header.html: -------------------------------------------------------------------------------- 1 | {%- if profile is not none -%} 2 | 3 | {%- if profile['about'] is not none -%} 4 | {%- set about=profile['about'] -%} 5 | {%- else -%} 6 | {%- set about='' -%} 7 | {%- endif -%} 8 | {%- if profile['updated_at'] is not none -%} 9 | {%- set updated_at=profile['updated_at'] -%} 10 | {%- else -%} 11 | {%- set updated_at='0' -%} 12 | {%- endif -%} 13 |
14 |
15 |
16 | {%- include 'profile/profile.tools.html' -%} 17 |
18 |
19 | {%- with p=profile -%} 20 | {%- include 'profile/profile.image.html' -%} 21 | {%- endwith -%} 22 |
23 |
24 |

{{profile['name'] | ident_string(profile['display_name'], profile['public_key']) | safe }}

25 | {%- if profile['nip05'] is not none -%}{{profile['nip05'] | nip05_valid(profile['nip05_validated']) | safe }} {{profile['nip05']}}   {%- endif -%}{{ profile['public_key'] | relationship | safe }} 26 |
{{about| linkify| process_note_content | safe}}
27 | {%- if website is not none -%} 28 |
29 | {{'web'| svg_icon('icon-sm')|safe}}{{ website }} 30 |
31 | {%- endif -%} 32 |
33 | {{'share'| svg_icon('icon-lg share-profile right')|safe}} 34 | {%- if has_ln -%} 35 | {{'lightning'| svg_icon('icon-lg lightning right')|safe}} 36 | {%- endif -%} 37 | {{'messages'| svg_icon('icon-lg')|safe}} 38 |
39 | {%- if is_me and page_id == 'profile-me' -%} 40 | 41 | {%- if profile['pic'] is not none -%} 42 | {%- set pic=profile['pic'] -%} 43 | {%- else -%} 44 | {%- set pic="/identicon?id="+profile['public_key'] -%} 45 | {%- endif -%} 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 | 60 | 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 | {%- endif -%} 78 | 79 |
80 |
81 | posts 82 | following {{n_following}} 83 | followers {{n_followers}} 84 | {%- if is_me -%} 85 | muted 86 | {%- endif -%} 87 | 88 |
89 | {%- endif -%} 90 |
91 | -------------------------------------------------------------------------------- /bija/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {%- block css -%} 8 | {%- endblock css -%} 9 | 10 | 11 | 12 | {{ title }} 13 | 14 | 15 | 16 | 17 |
18 | 21 | 32 | 35 |
36 |
37 | 38 |
39 | 56 |
57 | {%- block content -%} 58 | {%- endblock content -%} 59 |
60 |
61 | 62 |
63 | 72 | {%- block right_content -%} 73 | {%- endblock right_content -%} 74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | 82 | -------------------------------------------------------------------------------- /bija/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 10 | 11 |
12 |

Login

13 | 14 | {%- if message -%} 15 |

{{message}}

16 | {%- endif -%} 17 |
18 | 19 | {%- if stage == LoginState.SETUP -%} 20 |
21 |
22 | 23 |
24 |
25 |

Already have a profile you want to use?

26 |

Enter your key (either hex or npub format)

27 | 28 |
29 |
30 |
31 |
32 |
33 |

Add a login password. This will also encrypt your private key on disk.

34 | 35 |
36 |
37 |
38 | {%- elif stage == LoginState.WITH_PASSWORD -%} 39 |
40 | 41 | 42 |
43 |
44 | 45 | {%- elif stage == LoginState.SET_RELAYS -%} 46 |
47 |

In order to connect to the network you'll need to add at least 1 relay. You can add to and update your relay list at any time on the settings tab.

48 |

For your convenience below you will find handful of relays that the author has found to be reliable. Bija is not however associated with any of these.

49 | {%- for relay in data -%} 50 |
51 | 52 |
53 | {%- endfor -%} 54 | 55 | 56 |
57 |
58 | 59 | {%- elif stage == LoginState.NEW_KEYS -%} 60 |
61 |

Profile created!

62 |

* You can add other details, like name and description, on the profile page once you've completed setup.

63 |
64 |
65 |

Public Key:

66 |

Share your public key with friends so that they can find you on the network.

67 |

npub {{data['npub']}}

68 | 69 |

Private Key:

70 |

nsec {{data['nsec']}}

71 | 72 |
73 |
74 | {%- endif -%} 75 |
76 |
77 |

Welcome to Bija!

78 |

Please be aware that this is an alpha release of experimental software, is provided as is, and without any implication of warranty or liability.

79 |
80 |

If you would like to support my work then please consider sending a donation with bitcoin/lightning.

81 |

Lightning Addresstopliquor87@walletofsatoshi.com

82 |

On Chainbc1qawh3jreepchfw5nmfm9qpxdyla4dx0kynfnv27

83 |
84 |

Feel free to follow me on Nostr:

85 |

Nip-05CarlosAutonomous@rebelweb.co.uk

86 |

npubnpub1qqqqqqqut3z3jeuxu70c85slaqq4f87unr3vymukmnhsdzjahntsfmctgs

87 |
88 |
89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /lightning/bech32.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 Pieter Wuille 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | """Reference implementation for Bech32 and segwit addresses.""" 22 | 23 | 24 | CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" 25 | 26 | 27 | def bech32_polymod(values): 28 | """Internal function that computes the Bech32 checksum.""" 29 | generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] 30 | chk = 1 31 | for value in values: 32 | top = chk >> 25 33 | chk = (chk & 0x1ffffff) << 5 ^ value 34 | for i in range(5): 35 | chk ^= generator[i] if ((top >> i) & 1) else 0 36 | return chk 37 | 38 | 39 | def bech32_hrp_expand(hrp): 40 | """Expand the HRP into values for checksum computation.""" 41 | return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] 42 | 43 | 44 | def bech32_verify_checksum(hrp, data): 45 | """Verify a checksum given HRP and converted data characters.""" 46 | return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 47 | 48 | 49 | def bech32_create_checksum(hrp, data): 50 | """Compute the checksum values given HRP and data.""" 51 | values = bech32_hrp_expand(hrp) + data 52 | polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 53 | return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] 54 | 55 | 56 | def bech32_encode(hrp, data): 57 | """Compute a Bech32 string given HRP and data values.""" 58 | combined = data + bech32_create_checksum(hrp, data) 59 | return hrp + '1' + ''.join([CHARSET[d] for d in combined]) 60 | 61 | 62 | def bech32_decode(bech): 63 | """Validate a Bech32 string, and determine HRP and data.""" 64 | if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or 65 | (bech.lower() != bech and bech.upper() != bech)): 66 | return (None, None) 67 | bech = bech.lower() 68 | pos = bech.rfind('1') 69 | if pos < 1 or pos + 7 > len(bech): #or len(bech) > 90: 70 | return (None, None) 71 | if not all(x in CHARSET for x in bech[pos+1:]): 72 | return (None, None) 73 | hrp = bech[:pos] 74 | data = [CHARSET.find(x) for x in bech[pos+1:]] 75 | if not bech32_verify_checksum(hrp, data): 76 | return (None, None) 77 | return (hrp, data[:-6]) 78 | 79 | 80 | def convertbits(data, frombits, tobits, pad=True): 81 | """General power-of-2 base conversion.""" 82 | acc = 0 83 | bits = 0 84 | ret = [] 85 | maxv = (1 << tobits) - 1 86 | max_acc = (1 << (frombits + tobits - 1)) - 1 87 | for value in data: 88 | if value < 0 or (value >> frombits): 89 | return None 90 | acc = ((acc << frombits) | value) & max_acc 91 | bits += frombits 92 | while bits >= tobits: 93 | bits -= tobits 94 | ret.append((acc >> bits) & maxv) 95 | if pad: 96 | if bits: 97 | ret.append((acc << (tobits - bits)) & maxv) 98 | elif bits >= frombits or ((acc << (tobits - bits)) & maxv): 99 | return None 100 | return ret 101 | 102 | 103 | def decode(hrp, addr): 104 | """Decode a segwit address.""" 105 | hrpgot, data = bech32_decode(addr) 106 | if hrpgot != hrp: 107 | return (None, None) 108 | decoded = convertbits(data[1:], 5, 8, False) 109 | if decoded is None or len(decoded) < 2 or len(decoded) > 40: 110 | return (None, None) 111 | if data[0] > 16: 112 | return (None, None) 113 | if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: 114 | return (None, None) 115 | return (data[0], decoded) 116 | 117 | 118 | def encode(hrp, witver, witprog): 119 | """Encode a segwit address.""" 120 | ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5)) 121 | assert decode(hrp, ret) is not (None, None) 122 | return ret 123 | -------------------------------------------------------------------------------- /bija/templates/feed/feed.items.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {%- for thread in threads: -%} 4 | 5 | {%- if thread['responder_count'] > 0 -%} 6 | {%- set root_class = 'note-container root ancestor' -%} 7 | {%- else -%} 8 | {%- set root_class = 'note-container root' -%} 9 | {%- endif -%} 10 | 11 | {%- if thread['self'] is not none -%} 12 | {%- set post = thread['self'] -%} 13 | 14 | {%- if post['deleted'] is none or post is string -%} 15 | 16 | {%- if (thread['responder_count'] > 0 or thread['booster_count'] > 0) -%} 17 |
18 | {%- if thread['booster_count'] > 0 -%} 19 | {{ thread['boosters'] | boosters_string(thread['booster_count']) | safe }} 20 | {% if thread['responder_count'] > 0 %} ... {% endif %} 21 | {%- endif -%} 22 | {%- if thread['responder_count'] > 0 -%} 23 | {{ thread['responders'] | responders_string(thread['responder_count']) | safe }} 24 | {%- if thread['self'] is none -%} 25 | on a thread 26 | {%- endif -%} 27 | {%- endif -%} 28 |
29 | {%- endif -%} 30 | {%- if thread['self'] is string -%} 31 |
32 |
33 |
Event: {{thread['self']}} not yet seen on network
34 |
35 | {%- else -%} 36 |
37 |
38 |
39 | {{post['nip05'] | nip05_valid(post['nip05_validated']) | safe }} 40 | {%- with p=post -%} 41 | {%- include 'profile/profile.image.html' -%} 42 | {%- endwith -%} 43 |
44 |
45 | {%- set reply_chain = post['thread_root'] | get_thread_root(post['response_to'], post['id']) -%} 46 | {%- with note=post -%} 47 | {%- include 'note.html' -%} 48 | {%- endwith -%} 49 |
50 |
51 | {%- endif -%} 52 | {%- else -%} 53 | {%- with item=post -%} 54 | {%- include 'deleted.note.html' -%} 55 | {%- endwith -%} 56 | {%- endif -%} 57 | {%- endif -%} 58 | 59 | {%- if thread['response'] is not none -%} 60 | {%- set post = thread['response'] -%} 61 | 62 | {%- if thread['self'] is not none and post['response_to'] is not none -%} 63 |
64 |
65 |
66 | View thread 67 |
68 | {%- endif -%} 69 | 70 | {%- if post['deleted'] is none -%} 71 | {%- if thread['responder_count'] > 0 and thread['self'] is none -%} 72 |
73 | {{ thread['responders'] | responders_string(thread['responder_count']) | safe }} 74 | {%- if thread['self'] is none -%} 75 | on a thread 76 | {%- endif -%} 77 |
78 | {%- endif -%} 79 |
80 |
81 |
82 | {{post['nip05'] | nip05_valid(post['nip05_validated']) | safe }} 83 | {%- with p=post -%} 84 | {%- include 'profile/profile.image.html' -%} 85 | {%- endwith -%} 86 |
87 |
88 | {%- set reply_chain = post['thread_root'] | get_thread_root(post['response_to'], post['id']) -%} 89 | {%- with note=post -%} 90 | {%- include 'note.html' -%} 91 | {%- endwith -%} 92 |
93 |
94 |
95 |
96 | {%- if post['replies'] -%} 97 | View replies 98 | {%- endif -%} 99 |
100 | {%- else -%} 101 | {%- with item=post -%} 102 | {%- include 'deleted.note.html' -%} 103 | {%- endwith -%} 104 | {%- endif -%} 105 | {%- endif -%} 106 |
107 | {%- endfor -%} 108 |
-------------------------------------------------------------------------------- /lightning/lightning_address.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | from binascii import hexlify, unhexlify 3 | from lnaddr import lnencode, lndecode, LnAddr 4 | 5 | import argparse 6 | import time 7 | 8 | 9 | def encode(options): 10 | """ Convert options into LnAddr and pass it to the encoder 11 | """ 12 | addr = LnAddr() 13 | addr.currency = options.currency 14 | addr.fallback = options.fallback if options.fallback else None 15 | if options.amount: 16 | addr.amount = options.amount 17 | if options.timestamp: 18 | addr.date = int(options.timestamp) 19 | 20 | addr.paymenthash = unhexlify(options.paymenthash) 21 | 22 | if options.description: 23 | addr.tags.append(('d', options.description)) 24 | if options.description_hashed: 25 | addr.tags.append(('h', options.description_hashed)) 26 | if options.expires: 27 | addr.tags.append(('x', options.expires)) 28 | 29 | if options.fallback: 30 | addr.tags.append(('f', options.fallback)) 31 | 32 | for r in options.route: 33 | splits = r.split('/') 34 | route=[] 35 | while len(splits) >= 5: 36 | route.append((unhexlify(splits[0]), 37 | unhexlify(splits[1]), 38 | int(splits[2]), 39 | int(splits[3]), 40 | int(splits[4]))) 41 | splits = splits[5:] 42 | assert(len(splits) == 0) 43 | addr.tags.append(('r', route)) 44 | print(lnencode(addr, options.privkey)) 45 | 46 | 47 | def decode(options): 48 | a = lndecode(options.lnaddress, options.verbose) 49 | def tags_by_name(name, tags): 50 | return [t[1] for t in tags if t[0] == name] 51 | 52 | print("Signed with public key:", hexlify(a.pubkey.serialize())) 53 | print("Currency:", a.currency) 54 | print("Payment hash:", hexlify(a.paymenthash)) 55 | if a.amount: 56 | print("Amount:", a.amount) 57 | print("Timestamp: {} ({})".format(a.date, time.ctime(a.date))) 58 | 59 | for r in tags_by_name('r', a.tags): 60 | print("Route: ",end='') 61 | for step in r: 62 | print("{}/{}/{}/{}/{} ".format(hexlify(step[0]), hexlify(step[1]), step[2], step[3], step[4]), end='') 63 | print('') 64 | 65 | fallback = tags_by_name('f', a.tags) 66 | if fallback: 67 | print("Fallback:", fallback[0]) 68 | 69 | description = tags_by_name('d', a.tags) 70 | if description: 71 | print("Description:", description[0]) 72 | 73 | dhash = tags_by_name('h', a.tags) 74 | if dhash: 75 | print("Description hash:", hexlify(dhash[0])) 76 | 77 | expiry = tags_by_name('x', a.tags) 78 | if expiry: 79 | print("Expiry (seconds):", expiry[0]) 80 | 81 | for t in [t for t in a.tags if t[0] not in 'rdfhx']: 82 | print("UNKNOWN TAG {}: {}".format(t[0], hexlify(t[1]))) 83 | 84 | parser = argparse.ArgumentParser(description='Encode lightning address') 85 | subparsers = parser.add_subparsers(dest='subparser_name', 86 | help='sub-command help') 87 | 88 | parser_enc = subparsers.add_parser('encode', help='encode help') 89 | parser_dec = subparsers.add_parser('decode', help='decode help') 90 | 91 | parser_enc.add_argument('--currency', default='bc', 92 | help="What currency") 93 | parser_enc.add_argument('--route', action='append', default=[], 94 | help="Extra route steps of form pubkey/channel/feebase/feerate/cltv+") 95 | parser_enc.add_argument('--fallback', 96 | help='Fallback address for onchain payment') 97 | parser_enc.add_argument('--description', 98 | help='What is being purchased') 99 | parser_enc.add_argument('--description-hashed', 100 | help='What is being purchased (for hashing)') 101 | parser_enc.add_argument('--expires', type=int, 102 | help='Seconds before offer expires') 103 | parser_enc.add_argument('--timestamp', type=int, 104 | help='Timestamp (seconds after epoch) instead of now') 105 | parser_enc.add_argument('--no-amount', action="store_true", 106 | help="Don't encode amount") 107 | parser_enc.add_argument('amount', type=float, help='Amount in currency') 108 | parser_enc.add_argument('paymenthash', help='Payment hash (in hex)') 109 | parser_enc.add_argument('privkey', help='Private key (in hex)') 110 | parser_enc.set_defaults(func=encode) 111 | 112 | parser_dec.add_argument('lnaddress', help='Address to decode') 113 | parser_dec.add_argument('--rate', type=float, help='Convfersion amount for 1 currency unit') 114 | parser_dec.add_argument('--pubkey', help='Public key for the chanid') 115 | parser_dec.add_argument('--verbose', help='Print out extra decoding info', action="store_true") 116 | parser_dec.set_defaults(func=decode) 117 | 118 | if __name__ == "__main__": 119 | options = parser.parse_args() 120 | if not options.subparser_name: 121 | parser.print_help() 122 | else: 123 | options.func(options) -------------------------------------------------------------------------------- /bija/ws/relay.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from threading import Lock 4 | 5 | from websocket import WebSocketApp, WebSocketConnectionClosedException, setdefaulttimeout 6 | 7 | from bija.args import LOGGING_LEVEL 8 | from bija.ws.event import Event 9 | from bija.ws.filter import Filters 10 | from bija.ws.message_pool import MessagePool 11 | from bija.ws.message_type import RelayMessageType 12 | from bija.ws.subscription import Subscription 13 | 14 | import logging 15 | 16 | logger = logging.getLogger('websocket') 17 | logger.setLevel(LOGGING_LEVEL) 18 | logger.addHandler(logging.StreamHandler()) 19 | 20 | setdefaulttimeout(5) 21 | 22 | 23 | class RelayPolicy: 24 | def __init__(self, should_read: bool = True, should_write: bool = True) -> None: 25 | self.should_read = should_read 26 | self.should_write = should_write 27 | 28 | def to_json_object(self) -> dict[str, bool]: 29 | return { 30 | "read": self.should_read, 31 | "write": self.should_write 32 | } 33 | 34 | 35 | class Relay: 36 | def __init__( 37 | self, 38 | url: str, 39 | policy: RelayPolicy, 40 | message_pool: MessagePool, 41 | subscriptions: dict[str, Subscription] = {}) -> None: 42 | self.url = url 43 | self.policy = policy 44 | self.message_pool = message_pool 45 | self.subscriptions = subscriptions 46 | self.lock = Lock() 47 | self.ws = WebSocketApp( 48 | url, 49 | on_open=self._on_open, 50 | on_message=self._on_message, 51 | on_error=self._on_error, 52 | on_close=self._on_close, 53 | on_ping=self._on_ping, 54 | on_pong=self._on_pong 55 | ) 56 | self.active = False 57 | 58 | def connect(self, ssl_options: dict = None): 59 | self.ws.run_forever(sslopt=ssl_options, ping_interval=60, ping_timeout=10, ping_payload="2", reconnect=30) 60 | 61 | def close(self): 62 | self.ws.close() 63 | 64 | def publish(self, message: str): 65 | if self.policy.should_write: 66 | try: 67 | self.ws.send(message) 68 | except WebSocketConnectionClosedException: 69 | self.active = False 70 | logger.exception("failed to send message to {}".format(self.url)) 71 | 72 | def add_subscription(self, id, filters: Filters, batch=0): 73 | if self.policy.should_read: 74 | with self.lock: 75 | self.subscriptions[id] = Subscription(id, filters, self.url, batch) 76 | 77 | def close_subscription(self, id: str) -> None: 78 | with self.lock: 79 | self.publish('["CLOSE", "{}"]'.format(id)) 80 | self.subscriptions.pop(id) 81 | 82 | def update_subscription(self, id: str, filters: Filters) -> None: 83 | with self.lock: 84 | subscription = self.subscriptions[id] 85 | subscription.filters = filters 86 | 87 | def to_json_object(self) -> dict: 88 | return { 89 | "url": self.url, 90 | "policy": self.policy.to_json_object(), 91 | "subscriptions": [subscription.to_json_object() for subscription in self.subscriptions.values()] 92 | } 93 | 94 | def _on_open(self, class_obj): 95 | self.active = time.time() 96 | 97 | def _on_close(self, class_obj, status_code, message): 98 | self.active = False 99 | 100 | def _on_message(self, class_obj, message: str): 101 | self.active = time.time() 102 | if self._is_valid_message(message): 103 | self.message_pool.add_message(message, self.url) 104 | 105 | def _on_ping(self, class_obj, message): 106 | pass 107 | 108 | def _on_pong(self, class_obj, message): 109 | self.active = time.time() 110 | 111 | def _on_error(self, class_obj, error): 112 | pass 113 | 114 | def _is_valid_message(self, message: str) -> bool: 115 | message = message.strip("\n") 116 | if not message or message[0] != '[' or message[-1] != ']': 117 | return False 118 | 119 | message_json = json.loads(message) 120 | message_type = message_json[0] 121 | if not RelayMessageType.is_valid(message_type): 122 | return False 123 | if message_type == RelayMessageType.EVENT: 124 | if not len(message_json) == 3: 125 | return False 126 | 127 | subscription_id = message_json[1] 128 | with self.lock: 129 | if subscription_id not in self.subscriptions: 130 | return False 131 | 132 | e = message_json[2] 133 | event = Event(e['pubkey'], e['content'], e['created_at'], e['kind'], e['tags'], e['id'], e['sig']) 134 | if not event.verify(): 135 | return False 136 | 137 | with self.lock: 138 | subscription = self.subscriptions[subscription_id] 139 | 140 | if not subscription.filters.match(event): 141 | return False 142 | 143 | return True 144 | -------------------------------------------------------------------------------- /bija/ws/bech32.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017, 2020 Pieter Wuille 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | """Reference implementation for Bech32/Bech32m and segwit addresses.""" 22 | 23 | 24 | from enum import Enum 25 | 26 | class Encoding(Enum): 27 | """Enumeration type to list the various supported encodings.""" 28 | BECH32 = 1 29 | BECH32M = 2 30 | 31 | CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" 32 | BECH32M_CONST = 0x2bc830a3 33 | 34 | def bech32_polymod(values): 35 | """Internal function that computes the Bech32 checksum.""" 36 | generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] 37 | chk = 1 38 | for value in values: 39 | top = chk >> 25 40 | chk = (chk & 0x1ffffff) << 5 ^ value 41 | for i in range(5): 42 | chk ^= generator[i] if ((top >> i) & 1) else 0 43 | return chk 44 | 45 | 46 | def bech32_hrp_expand(hrp): 47 | """Expand the HRP into values for checksum computation.""" 48 | return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] 49 | 50 | 51 | def bech32_verify_checksum(hrp, data): 52 | """Verify a checksum given HRP and converted data characters.""" 53 | const = bech32_polymod(bech32_hrp_expand(hrp) + data) 54 | if const == 1: 55 | return Encoding.BECH32 56 | if const == BECH32M_CONST: 57 | return Encoding.BECH32M 58 | return None 59 | 60 | def bech32_create_checksum(hrp, data, spec): 61 | """Compute the checksum values given HRP and data.""" 62 | values = bech32_hrp_expand(hrp) + data 63 | const = BECH32M_CONST if spec == Encoding.BECH32M else 1 64 | polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const 65 | return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] 66 | 67 | 68 | def bech32_encode(hrp, data, spec): 69 | """Compute a Bech32 string given HRP and data values.""" 70 | combined = data + bech32_create_checksum(hrp, data, spec) 71 | return hrp + '1' + ''.join([CHARSET[d] for d in combined]) 72 | 73 | def bech32_decode(bech): 74 | """Validate a Bech32/Bech32m string, and determine HRP and data.""" 75 | if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or 76 | (bech.lower() != bech and bech.upper() != bech)): 77 | return (None, None, None) 78 | bech = bech.lower() 79 | pos = bech.rfind('1') 80 | if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: 81 | return (None, None, None) 82 | if not all(x in CHARSET for x in bech[pos+1:]): 83 | return (None, None, None) 84 | hrp = bech[:pos] 85 | data = [CHARSET.find(x) for x in bech[pos+1:]] 86 | spec = bech32_verify_checksum(hrp, data) 87 | if spec is None: 88 | return (None, None, None) 89 | return (hrp, data[:-6], spec) 90 | 91 | def convertbits(data, frombits, tobits, pad=True): 92 | """General power-of-2 base conversion.""" 93 | acc = 0 94 | bits = 0 95 | ret = [] 96 | maxv = (1 << tobits) - 1 97 | max_acc = (1 << (frombits + tobits - 1)) - 1 98 | for value in data: 99 | if value < 0 or (value >> frombits): 100 | return None 101 | acc = ((acc << frombits) | value) & max_acc 102 | bits += frombits 103 | while bits >= tobits: 104 | bits -= tobits 105 | ret.append((acc >> bits) & maxv) 106 | if pad: 107 | if bits: 108 | ret.append((acc << (tobits - bits)) & maxv) 109 | elif bits >= frombits or ((acc << (tobits - bits)) & maxv): 110 | return None 111 | return ret 112 | 113 | 114 | def decode(hrp, addr): 115 | """Decode a segwit address.""" 116 | hrpgot, data, spec = bech32_decode(addr) 117 | if hrpgot != hrp: 118 | return (None, None) 119 | decoded = convertbits(data[1:], 5, 8, False) 120 | if decoded is None or len(decoded) < 2 or len(decoded) > 40: 121 | return (None, None) 122 | if data[0] > 16: 123 | return (None, None) 124 | if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: 125 | return (None, None) 126 | if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M: 127 | return (None, None) 128 | return (data[0], decoded) 129 | 130 | 131 | def encode(hrp, witver, witprog): 132 | """Encode a segwit address.""" 133 | spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M 134 | ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec) 135 | if decode(hrp, ret) == (None, None): 136 | return None 137 | return ret 138 | -------------------------------------------------------------------------------- /bija/ws/subscription_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from bija.app import app, RELAY_MANAGER 5 | from bija.args import LOGGING_LEVEL 6 | from bija.db import BijaDB 7 | from bija.subscriptions import SubscribeThread, SubscribePrimary, SubscribeFeed, SubscribeProfile, SubscribeTopic, \ 8 | SubscribeMessages, SubscribeFollowerList 9 | 10 | DB = BijaDB(app.session) 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(LOGGING_LEVEL) 13 | 14 | 15 | class SubscriptionManager: 16 | def __init__(self): 17 | self.should_run = True 18 | self.max_connected_relays = 5 19 | self.subscriptions = {} 20 | 21 | def add_subscription(self, name, batch_count, **kwargs): 22 | self.subscriptions[name] = {'batch_count': batch_count, 'kwargs': kwargs, 'relays': {}} 23 | self.subscribe(name, []) 24 | 25 | def remove_subscription(self, name): 26 | del self.subscriptions[name] 27 | RELAY_MANAGER.close_subscription(name) 28 | 29 | def clear_subscriptions(self): 30 | remove_list = [] 31 | for sub in self.subscriptions: 32 | if sub != 'primary': 33 | remove_list.append(sub) 34 | for sub in remove_list: 35 | self.remove_subscription(sub) 36 | 37 | def next_round(self): 38 | 39 | for relay in RELAY_MANAGER.relays: 40 | for s in RELAY_MANAGER.relays[relay].subscriptions: 41 | sub = RELAY_MANAGER.relays[relay].subscriptions[s] 42 | if sub.paused and sub.paused < int(time.time()) - 30: 43 | sub.paused = False 44 | self.subscribe(s, [relay], 0, self.get_last_batch_upd(s, relay, 0)) 45 | 46 | def next_batch(self, relay, name): 47 | if name in self.subscriptions and self.subscriptions[name]['batch_count'] > 1: 48 | if relay in RELAY_MANAGER.relays and name in RELAY_MANAGER.relays[relay].subscriptions: 49 | 50 | if RELAY_MANAGER.relays[relay].subscriptions[name].batch >= self.subscriptions[name]['batch_count'] - 1: 51 | RELAY_MANAGER.relays[relay].subscriptions[name].batch = 0 52 | RELAY_MANAGER.relays[relay].subscriptions[name].paused = time.time() 53 | else: 54 | batch = RELAY_MANAGER.relays[relay].subscriptions[name].batch + 1 55 | self.subscribe( 56 | name, 57 | [relay], 58 | batch, 59 | self.get_last_batch_upd(name, relay, batch) 60 | ) 61 | 62 | def get_last_batch_upd(self, sub, relay, batch): 63 | if sub in self.subscriptions and relay in self.subscriptions[sub]['relays']: 64 | if batch in self.subscriptions[sub]['relays'][relay]: 65 | return self.subscriptions[sub]['relays'][relay][batch] 66 | return None 67 | 68 | def subscribe(self, name, relays, batch=0, ts=None): 69 | 70 | for relay in relays: 71 | if relay not in self.subscriptions[name]['relays']: 72 | self.subscriptions[name]['relays'][relay] = {} 73 | self.subscriptions[name]['relays'][relay][batch] = int(time.time()) 74 | 75 | if name == 'primary': 76 | SubscribePrimary( 77 | name, 78 | relays, 79 | batch, 80 | self.subscriptions[name]['kwargs']['pubkey'], 81 | ts 82 | ) 83 | elif name == 'main-feed': 84 | SubscribeFeed( 85 | name, 86 | relays, 87 | batch, 88 | self.subscriptions[name]['kwargs']['ids'], 89 | ts 90 | ) 91 | elif name == 'topic': 92 | SubscribeTopic( 93 | name, 94 | relays, 95 | batch, 96 | self.subscriptions[name]['kwargs']['term'], 97 | ts 98 | ) 99 | elif 'profile:' in name: 100 | if ts is not None: 101 | since = ts 102 | else: 103 | since = self.subscriptions[name]['kwargs']['since'] 104 | SubscribeProfile( 105 | name, 106 | relays, 107 | batch, 108 | self.subscriptions[name]['kwargs']['pubkey'], 109 | since, 110 | self.subscriptions[name]['kwargs']['ids'] 111 | ) 112 | elif 'followers:' in name: 113 | if ts is not None: 114 | since = ts 115 | else: 116 | since = self.subscriptions[name]['kwargs']['since'] 117 | SubscribeFollowerList( 118 | name, 119 | relays, 120 | batch, 121 | self.subscriptions[name]['kwargs']['pubkey'], 122 | since 123 | ) 124 | elif name == 'note-thread': 125 | SubscribeThread( 126 | name, 127 | relays, 128 | batch, 129 | self.subscriptions[name]['kwargs']['root'] 130 | ) 131 | elif name == 'messages': 132 | if ts is not None: 133 | since = ts 134 | else: 135 | since = self.subscriptions[name]['kwargs']['since'] 136 | SubscribeMessages( 137 | name, 138 | relays, 139 | batch, 140 | self.subscriptions[name]['kwargs']['pubkey'], 141 | since 142 | ) 143 | 144 | 145 | SUBSCRIPTION_MANAGER = SubscriptionManager() 146 | -------------------------------------------------------------------------------- /bija/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, UniqueConstraint 2 | from sqlalchemy.orm import relationship, declarative_base 3 | 4 | Base = declarative_base() 5 | 6 | 7 | class Event(Base): 8 | __tablename__ = "event" 9 | id = Column(Integer, primary_key=True, autoincrement=True) 10 | event_id = Column(String(64), unique=True) 11 | public_key = Column(String(64)) 12 | kind = Column(Integer) 13 | ts = Column(Integer) 14 | 15 | 16 | class EventRelay(Base): 17 | __tablename__ = "event_relay" 18 | id = Column(Integer, primary_key=True, autoincrement=True) 19 | event_id = Column(Integer, ForeignKey("event.id")) 20 | relay = Column(Integer, ForeignKey("relay.id")) 21 | 22 | UniqueConstraint(event_id, relay, name='uix_1', sqlite_on_conflict='IGNORE') 23 | 24 | 25 | class Profile(Base): 26 | __tablename__ = "profile" 27 | id = Column(Integer, primary_key=True) 28 | public_key = Column(String(64), unique=True) 29 | name = Column(String, nullable=True) 30 | display_name = Column(String, nullable=True) 31 | nip05 = Column(String, nullable=True) 32 | pic = Column(String, nullable=True) 33 | about = Column(String, nullable=True) 34 | updated_at = Column(Integer, default=0) 35 | followers_upd = Column(Integer) 36 | nip05_validated = Column(Boolean, default=False) 37 | blocked = Column(Boolean, default=False) 38 | relays = Column(String) 39 | raw = Column(String) 40 | 41 | notes = relationship("Note", back_populates="profile") 42 | 43 | class Follower(Base): 44 | __tablename__ = "follower" 45 | id = Column(Integer, primary_key=True) 46 | pk_1 = Column(Integer, ForeignKey("profile.public_key")) 47 | pk_2 = Column(Integer, ForeignKey("profile.public_key")) 48 | 49 | UniqueConstraint(pk_1, pk_2, name='uix_1', sqlite_on_conflict='IGNORE') 50 | 51 | 52 | class Note(Base): 53 | __tablename__ = "note" 54 | id = Column(String(64), primary_key=True) 55 | public_key = Column(String(64), ForeignKey("profile.public_key")) 56 | content = Column(String) 57 | response_to = Column(String(64)) 58 | thread_root = Column(String(64)) 59 | reshare = Column(String(64)) 60 | created_at = Column(Integer) 61 | members = Column(String) 62 | media = Column(String) 63 | hashtags = Column(String) 64 | seen = Column(Boolean, default=False) 65 | liked = Column(Boolean, default=False) 66 | shared = Column(Boolean, default=False) 67 | deleted = Column(Integer) 68 | raw = Column(String) 69 | 70 | profile = relationship("Profile", back_populates="notes") 71 | 72 | class PrivateMessage(Base): 73 | __tablename__ = "private_message" 74 | id = Column(String(64), primary_key=True) 75 | public_key = Column(String(64), ForeignKey("profile.public_key")) 76 | content = Column(String) 77 | is_sender = Column(Boolean) # true = public_key is sender, false I'm sender 78 | created_at = Column(Integer) 79 | seen = Column(Boolean, default=False) 80 | passed = Column(Boolean, default=False) 81 | raw = Column(String) 82 | 83 | class List(Base): 84 | __tablename__ = "list" 85 | id = Column(Integer, primary_key=True) 86 | public_key = Column(String(64), ForeignKey("profile.public_key")) 87 | name = Column(String) 88 | list = Column(String) 89 | following = Column(Boolean) 90 | 91 | UniqueConstraint(public_key, name, name='uix_1', sqlite_on_conflict='IGNORE') 92 | 93 | class Topic(Base): 94 | __tablename__ = "topic" 95 | id = Column(Integer, primary_key=True) 96 | tag = Column(String, unique=True) 97 | 98 | 99 | 100 | class Settings(Base): 101 | __tablename__ = "settings" 102 | key = Column(String(20), primary_key=True) 103 | name = Column(String) 104 | value = Column(String) 105 | 106 | 107 | class NoteReaction(Base): 108 | __tablename__ = "note_reactions" 109 | id = Column(String, primary_key=True) 110 | public_key = Column(String) 111 | event_id = Column(Integer) 112 | event_pk = Column(Integer) 113 | content = Column(String(7)) 114 | members = Column(String) 115 | 116 | 117 | class MessageReaction(Base): 118 | __tablename__ = "message_reactions" 119 | id = Column(Integer, primary_key=True) 120 | public_key = Column(String) 121 | event = Column(Integer, ForeignKey("private_message.id")) 122 | content = Column(String(7)) 123 | 124 | 125 | class ReactionTally(Base): 126 | __tablename__ = "reaction_tally" 127 | event_id = Column(String(64), primary_key=True) 128 | likes = Column(Integer, default=0) 129 | shares = Column(Integer, default=0) 130 | replies = Column(Integer, default=0) 131 | 132 | 133 | class Alert(Base): 134 | __tablename__ = "alerts" 135 | id = Column(Integer, primary_key=True) 136 | kind = Column(Integer) 137 | ts = Column(Integer) 138 | data = Column(String) 139 | seen = Column(Boolean, default=False) 140 | 141 | class Theme(Base): 142 | __tablename__ = "theme" 143 | id = Column(Integer, primary_key=True) # the id of the new event 144 | var = Column(String) 145 | val = Column(String) 146 | theme = Column(String) 147 | 148 | # Private keys 149 | class PK(Base): 150 | __tablename__ = "PK" 151 | id = Column(Integer, primary_key=True) 152 | key = Column(String) 153 | enc = Column(Boolean) # boolean 154 | 155 | 156 | class Relay(Base): 157 | __tablename__ = "relay" 158 | id = Column(Integer, primary_key=True) 159 | name = Column(String, unique=True) 160 | fav = Column(Boolean) 161 | send = Column(Boolean) 162 | receive = Column(Boolean) 163 | data = Column(String) 164 | 165 | 166 | class URL(Base): 167 | __tablename__ = "url" 168 | address = Column(String, primary_key=True) 169 | ts = Column(Integer) 170 | og = Column(String) 171 | -------------------------------------------------------------------------------- /bija/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | import urllib 5 | from enum import IntEnum 6 | import logging 7 | from typing import Any 8 | from urllib.error import HTTPError, URLError 9 | from urllib.parse import urlparse 10 | from urllib.request import Request 11 | 12 | import requests 13 | from bs4 import BeautifulSoup 14 | 15 | from bija.ws import bech32 16 | from bija.ws.bech32 import bech32_encode, bech32_decode, convertbits 17 | 18 | logger = logging.getLogger(__name__) 19 | FORMAT = "[%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" 20 | logging.basicConfig(format=FORMAT) 21 | logger.setLevel(logging.INFO) 22 | 23 | def hex64_to_bech32(prefix: str, hex_key: str): 24 | if is_hex_key(hex_key): 25 | converted_bits = bech32.convertbits(bytes.fromhex(hex_key), 8, 5) 26 | return bech32_encode(prefix, converted_bits, bech32.Encoding.BECH32) 27 | 28 | 29 | def bech32_to_hex64(prefix: str, b_key: str): 30 | hrp, data, spec = bech32_decode(b_key) 31 | if hrp != prefix: 32 | return False 33 | decoded = convertbits(data, 5, 8, False) 34 | key = bytes(decoded).hex() 35 | if not is_hex_key(key): 36 | return False 37 | return key 38 | 39 | 40 | # TODO: regex for this 41 | def is_bech32_key(hrp: str, key_str: str) -> bool: 42 | if key_str[:4] == hrp and len(key_str) == 63: 43 | return True 44 | return False 45 | 46 | 47 | def is_valid_name(name: str) -> bool: 48 | regex = re.compile(r'([a-zA-Z_0-9][a-zA-Z_\-0-9]+[a-zA-Z_0-9])+') 49 | return re.fullmatch(regex, name) is not None 50 | 51 | 52 | def get_at_tags(content: str) -> list[Any]: 53 | regex = re.compile(r'(@[a-zA-Z_0-9][a-zA-Z_\-0-9]+[a-zA-Z_0-9])+') 54 | return re.findall(regex, content) 55 | 56 | 57 | def get_hash_tags(content: str) -> list[Any]: 58 | regex = re.compile(r'\s\B#\w*[a-zA-Z]+\w*\W') 59 | return re.findall(regex, ' '+content+' ') 60 | 61 | 62 | def get_note_links(content: str) -> list[Any]: 63 | regex = re.compile(r'\Wnote1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58}\W') 64 | return re.findall(regex, ' '+content+' ') 65 | 66 | 67 | def get_embeded_tag_indexes(content: str): 68 | regex = re.compile(r'#\[([0-9]+)]') 69 | return re.findall(regex, content) 70 | 71 | 72 | def get_urls_in_string(content: str): 73 | regex = re.compile(r'((https?):((//)|(\\\\))+([\w\d:#@%/;$()~_?\+-=\\\.&](#!)?)*)') 74 | url = re.findall(regex, content) 75 | return [x[0] for x in url] 76 | 77 | 78 | def get_invoice(content: str): 79 | regex = re.compile(r'(lnbc[a-zA-Z0-9]*)') 80 | return re.search(regex, content) 81 | 82 | 83 | def url_linkify(content): 84 | urls = get_urls_in_string(content) 85 | for url in set(urls): 86 | parts = url.split('//') 87 | if len(parts) < 2: 88 | parts = ['', url] 89 | url = 'https://' + url 90 | if len(parts[1]) > 21: 91 | link_text = parts[1][:21] + '…' 92 | else: 93 | link_text = parts[1] 94 | content = content.replace( 95 | url, 96 | "{}".format(url, link_text)) 97 | return content 98 | 99 | 100 | def strip_tags(content: str): 101 | return BeautifulSoup(content, features="html.parser").get_text() 102 | 103 | 104 | def is_nip05(name: str): 105 | parts = name.split('@') 106 | if len(parts) == 2: 107 | if parts[0] == '_': 108 | test_str = 'test@{}'.format(parts[1]) 109 | else: 110 | test_str = name 111 | else: 112 | test_str = 'test@{}'.format(parts[0]) 113 | parts.insert(0, '_') 114 | 115 | regex = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}') 116 | match = re.fullmatch(regex, test_str) 117 | if match is not None: 118 | return parts 119 | else: 120 | return False 121 | 122 | 123 | def is_valid_relay(url: str) -> bool: 124 | regex = re.compile( 125 | r'^wss?://' 126 | r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... 127 | r'localhost|' # localhost... 128 | r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip 129 | r'(?::\d+)?' # optional port 130 | r'(?:/?|[/?]\S+)$', re.IGNORECASE) 131 | return re.fullmatch(regex, url) is not None 132 | 133 | 134 | def is_hex_key(k): 135 | return len(k) == 64 and all(c in '1234567890abcdefABCDEF' for c in k) 136 | 137 | 138 | class TimePeriod(IntEnum): 139 | HOUR = 60 * 60 140 | DAY = 60 * 60 * 24 141 | WEEK = 60 * 60 * 24 * 7 142 | 143 | def is_json(s): 144 | try: 145 | json.loads(s) 146 | except ValueError as e: 147 | return False 148 | return True 149 | 150 | def timestamp_minus(period: TimePeriod, multiplier: int = 1, start=False): 151 | if not start: 152 | start = int(time.time()) 153 | return start - (period * multiplier) 154 | 155 | 156 | def list_index_exists(lst, i): 157 | try: 158 | return lst[i] 159 | except IndexError: 160 | return None 161 | 162 | 163 | def request_relay_data(url): 164 | parts = urlparse(url) 165 | url = url.replace(parts.scheme, 'https') 166 | get = Request(url, headers={'Accept': 'application/nostr+json'}) 167 | try: 168 | with urllib.request.urlopen(get, timeout=2) as response: 169 | if response.status == 200: 170 | return response.read() 171 | return False 172 | except HTTPError as error: 173 | print(error.status, error.reason) 174 | return False 175 | except URLError as error: 176 | print(error.reason) 177 | return False 178 | except TimeoutError: 179 | print("Request timed out") 180 | return False 181 | 182 | def request_url_head(url): 183 | try: 184 | h = requests.head(url, timeout=1, headers={'User-Agent': 'Bija Nostr Client'}) 185 | if h.status_code == 200: 186 | return h.headers 187 | return False 188 | except requests.exceptions.Timeout as e: 189 | logging.error(e) 190 | return False 191 | except requests.exceptions.HTTPError as e: 192 | logging.error(e) 193 | return False 194 | except Exception as e: 195 | logging.error(e) 196 | return False 197 | 198 | -------------------------------------------------------------------------------- /bija/templates/settings.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- block css -%} 3 | {%- include 'css.html' -%} 4 | {%- endblock css -%} 5 | 6 | {%- block content -%} 7 |
8 |

Theme

9 |
10 |

11 | 20 |

21 |
22 |
23 |
24 |

Other style settings

25 |
26 |

27 | 28 |

29 | Small text
30 | Medium text
31 | Large text 32 |
33 |

34 |

35 | 36 |

37 |
Small
38 |
Medium
39 |
Large
40 |
41 |

42 |

43 | 44 |

45 | Small 46 | Medium
47 | Large 48 |
49 |

50 |

51 | 52 |

53 | {{'emoji'| svg_icon('icon-sm')|safe}} 54 | {{'emoji'| svg_icon('icon-mid')|safe}} 55 | {{'emoji'| svg_icon('icon-lg')|safe}} 56 |
57 |

58 |

59 | 60 |

61 | {{'profile'| svg_icon('pfp pfp-sm')|safe}} 62 | {{'profile'| svg_icon('pfp pfp-mid')|safe}} 63 | {{'profile'| svg_icon('pfp pfp-lg')|safe}} 64 |
65 |

66 | 67 |
68 |
69 |
70 |

Keys

71 |

Public key:

72 |

Share your public key with contacts so that they can find and connect with you on Nostr.

73 |

74 | npub 75 | {{k['public'][1]}} 76 | 77 | (preferred) 78 |

79 |

80 | HEX 81 | {{k['public'][0]}} 82 | 83 | (old style) 84 |

85 |
86 |

Private key:

87 |

Keep your private key backed up somewhere secure and never share it with anyone else.

88 | 89 |
90 | 91 |
92 |
93 |
94 |

Relays

95 |
96 | {%- include 'relays.list.html' -%} 97 |
98 | 99 |
100 | 101 |
102 |

Proof of work

103 |
104 |

105 | 106 | Default difficulty for notes you create 107 |

108 |

109 | 110 | Default difficulty for encrypted messages you create 111 |

112 |
113 |

114 | 115 | Required difficulty for incoming notes from unknown accounts 116 |

117 |

118 | 119 | Required difficulty for incoming encrypted messages from unknown accounts 120 |

121 | 122 |
123 |
124 | 125 |
126 |

Cloudinary

127 |

Add media uploads to your posts by adding a cloudinary account.

128 |

You'll need the name of your cloud and the name of an upload preset (create one in the Uploads section of your Cloudinary settings)

129 |
130 | 131 | 132 | 133 |
134 |
135 | 136 |
137 |
138 | 139 |
140 |
141 | {%- endblock content -%} -------------------------------------------------------------------------------- /bija/notes.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | 5 | from bija.app import app 6 | from bija.args import LOGGING_LEVEL 7 | from bija.db import BijaDB 8 | from bija.settings import SETTINGS 9 | 10 | DB = BijaDB(app.session) 11 | logger = logging.getLogger(__name__) 12 | logger.setLevel(LOGGING_LEVEL) 13 | 14 | 15 | class FeedThread: 16 | def __init__(self, notes): 17 | logger.info('FEED THREAD') 18 | self.notes = notes 19 | self.processed_notes = [] 20 | self.threads = [] 21 | self.roots = [] 22 | self.ids = set() 23 | self.last_ts = int(time.time()) 24 | self.get_roots() 25 | self.construct_threads() 26 | 27 | def get_roots(self): 28 | logger.info('get roots') 29 | roots = [] 30 | for note in self.notes: 31 | note = dict(note) 32 | self.last_ts = note['created_at'] 33 | if note['reshare'] is not None: 34 | self.add_id(note['reshare']) 35 | if len(note['content'].strip()) < 1: 36 | roots.append(note['reshare']) 37 | note['boost'] = True 38 | else: 39 | roots.append(note['id']) 40 | elif note['thread_root'] is not None: 41 | roots.append(note['thread_root']) 42 | self.add_id(note['thread_root']) 43 | elif note['response_to'] is not None: 44 | roots.append(note['response_to']) 45 | self.add_id(note['response_to']) 46 | elif note['thread_root'] is None and note['response_to'] is None: 47 | roots.append(note['id']) 48 | self.add_id(note['id']) 49 | self.processed_notes.append(note) 50 | self.roots = list(dict.fromkeys(roots)) 51 | ids = [n['id'] for n in self.notes] 52 | 53 | fids = [x for x in self.roots if x not in ids] 54 | extra_notes = DB.get_feed(int(time.time()), SETTINGS.get('pubkey'), {'id_list': fids}) 55 | if extra_notes is not None: 56 | self.processed_notes += extra_notes 57 | for root in self.roots: 58 | self.threads.append({ 59 | 'self': None, 60 | 'id': root, 61 | 'response': None, 62 | 'responders': {}, 63 | 'responder_count': 0, 64 | 'boosters':{}, 65 | 'booster_count': 0 66 | }) 67 | 68 | def construct_threads(self): 69 | for note in self.processed_notes: 70 | note = dict(note) 71 | if note['reshare'] is not None and 'boost' not in note: 72 | self.add_id(note['reshare']) 73 | thread = next((sub for sub in self.threads if sub['id'] == note['id']), None) 74 | if thread is not None: 75 | thread['self'] = note 76 | elif note['thread_root'] is not None: 77 | thread = next((sub for sub in self.threads if sub['id'] == note['thread_root']), None) 78 | if thread is not None: 79 | if thread['response'] is None: 80 | thread['response'] = note 81 | if note['public_key'] not in thread['responders']: 82 | thread['responder_count'] += 1 83 | if len(thread['responders']) < 2: 84 | thread['responders'][note['public_key']] = note['name'] 85 | elif 'boost' in note: 86 | thread = next((sub for sub in self.threads if sub['id'] == note['reshare']), None) 87 | if thread is not None: 88 | if note['public_key'] not in thread['boosters']: 89 | thread['booster_count'] += 1 90 | if len(thread['boosters']) < 2: 91 | thread['boosters'][note['public_key']] = note['name'] 92 | if thread['self'] is None: 93 | thread['self'] = thread['id'] 94 | 95 | def add_id(self, note_id): 96 | logger.info('add id: {}'.format(note_id)) 97 | if note_id not in self.ids: 98 | self.ids.add(note_id) 99 | 100 | class NoteThread: 101 | def __init__(self, note_id): 102 | logger.info('NOTE THREAD') 103 | self.id = note_id 104 | self.is_root = False 105 | self.root_id = None 106 | self.profiles = [] 107 | self.note_ids = [self.id] 108 | self.public_keys = [] 109 | self.note = None 110 | self.root = None 111 | self.parent = None 112 | self.replies = [] 113 | 114 | self.process() 115 | 116 | 117 | def process(self): 118 | self.note = self.get_note() 119 | if not self.is_root: 120 | self.determine_root() 121 | self.get_root() 122 | if self.note is not None and 'response_to' in self.note: 123 | self.get_parent() 124 | 125 | self.get_replies() 126 | 127 | self.get_profile_briefs() 128 | 129 | 130 | def get_note(self): 131 | logger.info('get note') 132 | n = DB.get_note(SETTINGS.get('pubkey'), self.id) 133 | if n is not None: 134 | n = dict(n) 135 | n['current'] = True 136 | if n['thread_root'] is None: 137 | self.is_root = True 138 | self.root_id = self.id 139 | n['class'] = 'main root' 140 | else: 141 | n['class'] = 'main' 142 | n['reshare'] = self.get_reshare(n) 143 | self.add_members(n) 144 | return n 145 | return self.id 146 | 147 | def get_parent(self): 148 | logger.info('get parent') 149 | p = DB.get_note(SETTINGS.get('pubkey'), self.note['response_to']) 150 | if p is not None: 151 | p = dict(p) 152 | p['reshare'] = self.get_reshare(p) 153 | p['class'] = 'ancestor' 154 | self.add_members(p) 155 | self.note_ids.append(p['id']) 156 | self.parent = p 157 | else: 158 | self.parent = self.note['response_to'] 159 | 160 | def get_replies(self): 161 | logger.info('get replies') 162 | replies = DB.get_feed(int(time.time()), SETTINGS.get('pubkey'), {'replies': self.id}) 163 | if replies is not None: 164 | for note in replies: 165 | n = dict(note) 166 | if n['response_to'] == self.id or (n['thread_root'] == self.id and n['response_to'] is None): 167 | n['reshare'] = self.get_reshare(n) 168 | n['class'] = 'reply' 169 | self.replies.append(n) 170 | self.add_members(n) 171 | self.note_ids.append(n['id']) 172 | 173 | def get_reshare(self, note): 174 | logger.info('get reshare') 175 | if note['reshare'] is not None: 176 | reshare = DB.get_note(SETTINGS.get('pubkey'), note['reshare']) 177 | if reshare is not None: 178 | return reshare 179 | else: 180 | return note['reshare'] 181 | return None 182 | 183 | def add_public_keys(self, public_keys: list): 184 | logger.info('add pub keys') 185 | for k in public_keys: 186 | if k not in self.public_keys: 187 | self.public_keys.append(k) 188 | 189 | def get_profile_briefs(self): 190 | logger.info('get profile briefs') 191 | self.profiles = DB.get_profile_briefs(self.public_keys) 192 | 193 | def add_members(self, note): 194 | logger.info('add members') 195 | public_keys = [note['public_key']] 196 | public_keys = json.loads(note['members']) + public_keys 197 | self.add_public_keys(public_keys) 198 | 199 | def get_root(self): 200 | logger.info('get root') 201 | n = DB.get_note(SETTINGS.get('pubkey'), self.root_id) 202 | if n is not None: 203 | n = dict(n) 204 | n['class'] = 'root' 205 | n['reshare'] = self.get_reshare(n) 206 | self.root = n 207 | self.add_members(n) 208 | self.note_ids.append(n['id']) 209 | else: 210 | self.root = self.root_id 211 | 212 | def determine_root(self): 213 | logger.info('determine root') 214 | if self.note is not None and type(self.note) == dict: 215 | if self.note['thread_root'] is not None: 216 | self.root_id = self.note['thread_root'] 217 | 218 | 219 | class BoostsThread: 220 | def __init__(self, note_id): 221 | logger.info('BOOSTS THREAD') 222 | self.id = note_id 223 | self.note = DB.get_note(SETTINGS.get('pubkey'), note_id) 224 | self.boosts = [] 225 | boosts = DB.get_feed(int(time.time()), SETTINGS.get('pubkey'), {'boost_id': note_id}) 226 | for boost in boosts: 227 | boost = dict(boost) 228 | boost['reshare'] = self.note 229 | self.boosts.append(boost) -------------------------------------------------------------------------------- /bija/submissions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | 5 | from bija.app import app 6 | from bija.args import LOGGING_LEVEL 7 | from bija.db import BijaDB 8 | from bija.helpers import is_hex_key, get_at_tags, get_hash_tags, get_note_links, bech32_to_hex64 9 | from bija.settings import SETTINGS 10 | from bija.ws.event import EventKind, Event 11 | from bija.ws.key import PrivateKey 12 | from bija.ws.message_type import ClientMessageType 13 | from bija.ws.pow import mine_event 14 | from bija.app import RELAY_MANAGER 15 | 16 | DB = BijaDB(app.session) 17 | logger = logging.getLogger(__name__) 18 | logger.setLevel(LOGGING_LEVEL) 19 | 20 | 21 | class Submit: 22 | def __init__(self): 23 | logger.info('SUBMISSION initiated') 24 | self.tags = [] 25 | self.content = "" 26 | self.event_id = None 27 | self.kind = EventKind.TEXT_NOTE 28 | self.created_at = int(time.time()) 29 | r = DB.get_preferred_relay() 30 | self.preferred_relay = r.name 31 | self.pow_difficulty = None 32 | 33 | def send(self): 34 | self.tags.append(['client', 'BIJA']) 35 | if self.pow_difficulty is None or self.pow_difficulty < 1: 36 | event = Event(SETTINGS.get('pubkey'), self.content, tags=self.tags, created_at=self.created_at, kind=self.kind) 37 | else: 38 | logger.info('mine event') 39 | event = mine_event(self.content, self.pow_difficulty, SETTINGS.get('pubkey'), self.kind, self.tags) 40 | event.sign(SETTINGS.get('privkey')) 41 | self.event_id = event.id 42 | message = json.dumps([ClientMessageType.EVENT, event.to_json_object()], ensure_ascii=False) 43 | logger.info('SUBMIT: {}'.format(message)) 44 | RELAY_MANAGER.publish_message(message) 45 | logger.info('PUBLISHED') 46 | 47 | class SubmitBoost(Submit): 48 | def __init__(self, note_id, content): 49 | super().__init__() 50 | logger.info('SUBMIT boost') 51 | self.kind = EventKind.BOOST 52 | self.tags.append(['e', note_id]) 53 | self.content = content 54 | self.send() 55 | 56 | class SubmitDelete(Submit): 57 | def __init__(self, ids, reason=""): 58 | super().__init__() 59 | logger.info('SUBMIT delete') 60 | self.kind = EventKind.DELETE 61 | self.ids = ids 62 | self.content = reason 63 | self.compose() 64 | self.send() 65 | 66 | def compose(self): 67 | logger.info('compose') 68 | for eid in self.ids: 69 | if is_hex_key(eid): 70 | self.tags.append(['e', eid]) 71 | 72 | 73 | class SubmitProfile(Submit): 74 | def __init__(self, data): 75 | super().__init__() 76 | logger.info('SUBMIT profile') 77 | self.kind = EventKind.SET_METADATA 78 | self.content = json.dumps(data) 79 | self.send() 80 | 81 | 82 | class SubmitLike(Submit): 83 | def __init__(self, note_id, content="+"): 84 | super().__init__() 85 | logger.info('SUBMIT like') 86 | self.content = content 87 | self.note_id = note_id 88 | self.kind = EventKind.REACTION 89 | self.compose() 90 | self.send() 91 | 92 | def compose(self): 93 | logger.info('compose') 94 | note = DB.get_note(SETTINGS.get('pubkey'), self.note_id) 95 | members = json.loads(note.members) 96 | for m in members: 97 | if is_hex_key(m) and m != note.public_key: 98 | self.tags.append(["p", m, self.preferred_relay]) 99 | self.tags.append(["p", note.public_key, self.preferred_relay]) 100 | self.tags.append(["e", note.id, self.preferred_relay]) 101 | 102 | 103 | class SubmitNote(Submit): 104 | def __init__(self, data, members=[], pow_difficulty=None): 105 | super().__init__() 106 | logger.info('SUBMIT note') 107 | self.data = data 108 | self.members = members 109 | self.response_to = None 110 | self.thread_root = None 111 | self.reshare = None 112 | if pow_difficulty is not None: 113 | self.pow_difficulty = int(pow_difficulty) 114 | self.compose() 115 | self.send() 116 | # self.store() 117 | 118 | def compose(self): 119 | logger.info('compose') 120 | data = self.data 121 | if 'quote_id' in data: 122 | logger.info('has quote id') 123 | i = '0' 124 | if self.pow_difficulty is not None and self.pow_difficulty > 0: 125 | i = '1' 126 | self.content = "{} #[{}]".format(data['comment'], i) 127 | self.reshare = data['quote_id'] 128 | self.tags.append(["e", data['quote_id']]) 129 | if self.members is not None: 130 | for m in self.members: 131 | if is_hex_key(m): 132 | self.tags.append(["p", m, self.preferred_relay]) 133 | elif 'new_post' in data: 134 | logger.info('is new post') 135 | self.content = data['new_post'] 136 | elif 'reply' in data: 137 | logger.info('is reply') 138 | self.content = data['reply'] 139 | if self.members is not None: 140 | for m in self.members: 141 | if is_hex_key(m): 142 | self.tags.append(["p", m, self.preferred_relay]) 143 | if 'parent_id' not in data or 'thread_root' not in data: 144 | self.event_id = False 145 | elif len(data['parent_id']) < 1 and is_hex_key(data['thread_root']): 146 | self.thread_root = data['thread_root'] 147 | self.tags.append(["e", data['thread_root'], self.preferred_relay, "root"]) 148 | elif is_hex_key(data['parent_id']) and is_hex_key(data['thread_root']): 149 | self.thread_root = data['thread_root'] 150 | self.response_to = data['parent_id'] 151 | self.tags.append(["e", data['parent_id'], self.preferred_relay, "reply"]) 152 | self.tags.append(["e", data['thread_root'], self.preferred_relay, "root"]) 153 | else: 154 | self.event_id = False 155 | if 'uploads' in data: 156 | logger.info('has uploads') 157 | self.content += data['uploads'] 158 | self.process_hash_tags() 159 | self.process_mentions() 160 | 161 | def process_mentions(self): 162 | logger.info('process mentions') 163 | matches = get_at_tags(self.content) 164 | note_matches = get_note_links(self.content) 165 | offset = 1 166 | if self.pow_difficulty is not None and self.pow_difficulty > 0: 167 | offset = 0 168 | for match in matches: 169 | name = DB.get_profile_by_name_or_pk(match[1:]) 170 | if name is not None: 171 | self.tags.append(["p", name['public_key']]) 172 | index = len(self.tags) - offset 173 | self.content = self.content.replace(match, "#[{}]".format(index)) 174 | for match in note_matches: 175 | match = match[1:-1] 176 | self.tags.append(["e", bech32_to_hex64('note', match)]) 177 | index = len(self.tags) - offset 178 | self.content = self.content.replace(match, "#[{}]".format(index)) 179 | 180 | def process_hash_tags(self): 181 | logger.info('process hashtags') 182 | matches = get_hash_tags(self.content) 183 | if len(matches) > 0: 184 | for match in matches: 185 | self.tags.append(["t", match[1:].strip()]) 186 | 187 | 188 | class SubmitRelayList(Submit): 189 | def __init__(self): 190 | super().__init__() 191 | logger.info('SUBMIT relay list') 192 | self.kind = EventKind.RELAY_LIST 193 | self.compose() 194 | self.send() 195 | 196 | def compose(self): 197 | logger.info('compose') 198 | relays = DB.get_relays(fav=True) 199 | for r in relays: 200 | if r.send and r.receive: 201 | self.tags.append(["r", r.name]) 202 | elif r.send: 203 | self.tags.append(["r", r.name, "write"]) 204 | elif r.receive: 205 | self.tags.append(["r", r.name, "read"]) 206 | 207 | 208 | class SubmitFollowList(Submit): 209 | def __init__(self): 210 | super().__init__() 211 | logger.info('SUBMIT follow list') 212 | self.kind = EventKind.CONTACTS 213 | self.compose() 214 | self.send() 215 | 216 | def compose(self): 217 | logger.info('compose') 218 | pk_list = DB.get_following_pubkeys(SETTINGS.get('pubkey')) 219 | for pk in pk_list: 220 | self.tags.append(["p", pk]) 221 | 222 | class SubmitBlockList(Submit): 223 | def __init__(self, l: list): 224 | super().__init__() 225 | logger.info('SUBMIT block list') 226 | self.kind = EventKind.BLOCK_LIST 227 | encrypted = encrypt(json.dumps(l), SETTINGS.get('pubkey')) 228 | if encrypted: 229 | self.content = encrypted 230 | self.send() 231 | 232 | class SubmitPersonList(Submit): 233 | def __init__(self, name, l: list): 234 | super().__init__() 235 | logger.info('SUBMIT block list') 236 | l = list(set(tuple(x) for x in l)) 237 | self.tags.append(['d', name]) 238 | self.kind = EventKind.PERSON_LIST 239 | encrypted = encrypt(json.dumps(l), SETTINGS.get('pubkey')) 240 | if encrypted: 241 | self.content = encrypted 242 | self.send() 243 | 244 | 245 | class SubmitEncryptedMessage(Submit): 246 | def __init__(self, data, pow_difficulty=None): 247 | super().__init__() 248 | logger.info('SUBMIT encrypted message') 249 | self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE 250 | self.data = data 251 | if pow_difficulty is not None: 252 | self.pow_difficulty = int(pow_difficulty) 253 | self.compose() 254 | 255 | def compose(self): 256 | logger.info('compose') 257 | pk = None 258 | txt = None 259 | for v in self.data: 260 | if v[0] == "new_message": 261 | txt = v[1] 262 | elif v[0] == "new_message_pk": 263 | pk = v[1] 264 | if pk is not None and txt is not None: 265 | self.tags.append(['p', pk]) 266 | self.content = encrypt(txt, pk) 267 | self.send() 268 | else: 269 | self.event_id = False 270 | 271 | def encrypt(message, public_key): 272 | logger.info('encrypt message') 273 | try: 274 | k = bytes.fromhex(SETTINGS.get('privkey')) 275 | pk = PrivateKey(k) 276 | return pk.encrypt_message(message, public_key) 277 | except ValueError: 278 | return False 279 | -------------------------------------------------------------------------------- /bija/subscriptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from bija.app import app 5 | from bija.args import LOGGING_LEVEL 6 | from bija.db import BijaDB 7 | from bija.helpers import timestamp_minus, TimePeriod 8 | from bija.settings import SETTINGS 9 | from bija.ws.event import EventKind 10 | from bija.ws.filter import Filter, Filters 11 | from bija.ws.message_type import ClientMessageType 12 | from bija.app import RELAY_MANAGER 13 | 14 | DB = BijaDB(app.session) 15 | logger = logging.getLogger(__name__) 16 | logger.setLevel(LOGGING_LEVEL) 17 | 18 | 19 | class Subscribe: 20 | def __init__(self, name, relays=[], batch=0): 21 | self.name = name 22 | self.relays = relays 23 | self.batch = batch 24 | logger.info('SUBSCRIBE: {} | Relays {} | Batch {}'.format(name, relays, batch)) 25 | self.filters = None 26 | 27 | def send(self): 28 | request = [ClientMessageType.REQUEST, self.name] 29 | request.extend(self.filters.to_json_array()) 30 | logger.info('add subscription to relay manager') 31 | 32 | if len(self.relays) < 1: # publish to all 33 | for r in RELAY_MANAGER.relays.values(): 34 | if r.policy.should_read: 35 | self.relays.append(r.url) 36 | for r in self.relays: 37 | if r in RELAY_MANAGER.relays.keys(): 38 | logger.info( 39 | 'publish subscription {} | Relay {} | Batch {}'.format(self.name, r, self.batch)) 40 | RELAY_MANAGER.relays[r].add_subscription(self.name, self.filters, self.batch) 41 | message = json.dumps(request) 42 | RELAY_MANAGER.relays[r].publish(message) 43 | 44 | 45 | @staticmethod 46 | def required_pow(setting: str = 'pow_required'): 47 | required_pow = SETTINGS.get(setting) 48 | if required_pow is not None and int(required_pow) > 0: 49 | return int(int(required_pow) / 4) * "0" 50 | return None 51 | 52 | 53 | class SubscribePrimary(Subscribe): 54 | def __init__(self, name, relay, batch, pubkey, since=None): 55 | super().__init__(name, relay, batch) 56 | self.pubkey = pubkey 57 | self.since = since 58 | if since is None: 59 | self.set_since() 60 | self.build_filters() 61 | self.send() 62 | 63 | def set_since(self): 64 | latest_event = DB.latest_event() 65 | if latest_event is not None: 66 | self.since = timestamp_minus(TimePeriod.HOUR, start=latest_event.ts) 67 | else: 68 | self.since = timestamp_minus(TimePeriod.DAY) 69 | 70 | def build_filters(self): 71 | logger.info('build subscription filters') 72 | kinds = [EventKind.TEXT_NOTE, EventKind.BOOST, 73 | EventKind.RECOMMEND_RELAY, 74 | EventKind.ENCRYPTED_DIRECT_MESSAGE, 75 | EventKind.DELETE, 76 | EventKind.REACTION, 77 | EventKind.PERSON_LIST] 78 | profile_filter = Filter(authors=[self.pubkey], kinds=kinds, since=self.since) 79 | contacts_filter = Filter(authors=[self.pubkey], kinds=[EventKind.CONTACTS], limit=1) 80 | blocked_filter = Filter(authors=[self.pubkey], kinds=[EventKind.BLOCK_LIST], limit=1) 81 | md_filter = Filter(authors=[self.pubkey], kinds=[EventKind.SET_METADATA], limit=1) 82 | kinds = [EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION, EventKind.ENCRYPTED_DIRECT_MESSAGE] 83 | mentions_filter = Filter(tags={'#p': [self.pubkey]}, kinds=kinds, since=self.since) 84 | followers_filter = Filter(tags={'#p': [self.pubkey]}, kinds=[EventKind.CONTACTS, EventKind.RELAY_LIST]) 85 | messages_filter = Filter(authors=[self.pubkey], kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], 86 | since=self.since) 87 | f = [profile_filter, contacts_filter, md_filter, mentions_filter, messages_filter, blocked_filter, followers_filter] 88 | start = int(self.batch * 256) 89 | end = start + 256 90 | following_pubkeys = DB.get_following_pubkeys(SETTINGS.get('pubkey'), start, end) 91 | 92 | if len(following_pubkeys) > 0: 93 | following_filter = Filter( 94 | authors=following_pubkeys, 95 | kinds=[EventKind.SET_METADATA, EventKind.CONTACTS, EventKind.ENCRYPTED_DIRECT_MESSAGE, EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION, EventKind.DELETE], 96 | since=self.since 97 | ) 98 | f.append(following_filter) 99 | 100 | topics = DB.get_topics() 101 | if len(topics) > 0: 102 | difficulty = self.required_pow() 103 | t = [] 104 | for topic in topics: 105 | t.append(topic.tag) 106 | topics_filter = Filter( 107 | kinds=[EventKind.TEXT_NOTE, EventKind.BOOST], 108 | ids=[difficulty], 109 | tags={"#t": t}, 110 | since=self.since 111 | ) 112 | f.append(topics_filter) 113 | 114 | self.filters = Filters(f) 115 | 116 | 117 | class SubscribeTopic(Subscribe): 118 | def __init__(self, name, relay, batch, term, since=None): 119 | super().__init__(name, relay, batch) 120 | self.term = term 121 | self.since = since 122 | self.set_since() 123 | self.build_filters() 124 | self.send() 125 | 126 | def set_since(self): 127 | if self.since is None: 128 | self.since = timestamp_minus(TimePeriod.DAY) 129 | 130 | def build_filters(self): 131 | logger.info('build subscription filters') 132 | difficulty = self.required_pow() 133 | ids = None 134 | if difficulty is not None: 135 | logger.info('calculated difficulty {}'.format(difficulty)) 136 | ids = [difficulty] 137 | f = [ 138 | Filter(kinds=[EventKind.TEXT_NOTE, EventKind.BOOST], tags={'#t': [self.term]}, 139 | since=timestamp_minus(TimePeriod.WEEK * 4), ids=ids) 140 | ] 141 | self.filters = Filters(f) 142 | 143 | 144 | class SubscribeProfile(Subscribe): 145 | def __init__(self, name, relay, batch, pubkey, since, ids): 146 | super().__init__(name, relay, batch) 147 | self.ids = ids 148 | self.pubkey = pubkey 149 | if not DB.is_blocked(pubkey): 150 | self.since = since 151 | self.build_filters() 152 | self.send() 153 | 154 | def build_filters(self): 155 | logger.info('build subscription filters') 156 | f = [ 157 | Filter(authors=[self.pubkey], kinds=[EventKind.RELAY_LIST], limit=1), 158 | Filter(authors=[self.pubkey], kinds=[EventKind.SET_METADATA, EventKind.CONTACTS]), 159 | # Filter(tags={'#p': [self.pubkey]}, kinds=[EventKind.CONTACTS]), 160 | Filter(ids=self.ids, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION]) 161 | ] 162 | if self.since is None: 163 | main_filter = Filter(authors=[self.pubkey], 164 | kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.DELETE, EventKind.REACTION], 165 | limit=100) 166 | else: 167 | main_filter = Filter(authors=[self.pubkey], 168 | kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.DELETE, EventKind.REACTION], 169 | since=self.since) 170 | f.append(main_filter) 171 | start = int(self.batch * 256) 172 | end = start + 256 173 | followers = DB.get_following_pubkeys(self.pubkey, start, end) 174 | if followers is not None and len(followers) > 0: 175 | contacts_filter = Filter(authors=followers, kinds=[EventKind.SET_METADATA]) 176 | f.append(contacts_filter) 177 | self.filters = Filters(f) 178 | 179 | class SubscribeFollowerList(Subscribe): 180 | def __init__(self, name, relay, batch, pubkey, since): 181 | super().__init__(name, relay, batch) 182 | self.pubkey = pubkey 183 | self.since = since 184 | self.build_filters() 185 | self.send() 186 | 187 | def build_filters(self): 188 | f = [Filter(tags={'#p': [self.pubkey]}, kinds=[EventKind.CONTACTS], since=self.since)] 189 | start = int(self.batch * 256) 190 | end = start + 256 191 | followers = DB.get_follower_pubkeys(self.pubkey, start, end) 192 | if followers is not None and len(followers) > 0: 193 | f.append(Filter(authors=followers, kinds=[EventKind.CONTACTS], since=self.since)) 194 | self.filters = Filters(f) 195 | 196 | class SubscribeThread(Subscribe): 197 | def __init__(self, name, relay, batch, root): 198 | super().__init__(name, relay, batch) 199 | self.batch = batch 200 | self.root = root 201 | self.build_filters() 202 | self.send() 203 | 204 | def build_filters(self): 205 | logger.info('build subscription filters') 206 | filters = [] 207 | ids = DB.get_note_thread_ids(self.root) 208 | if ids is None: 209 | ids = [self.root] 210 | filters.append(Filter(ids=ids, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION])) 211 | difficulty = self.required_pow() 212 | if difficulty is not None: 213 | start = int(self.batch * 256) 214 | end = start + 256 215 | pks = DB.get_following_pubkeys(SETTINGS.get('pubkey'), start, end) 216 | filters.append( 217 | Filter(tags={'#e': ids, '#p': pks}, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION])) 218 | filters.append( 219 | Filter(tags={'#e': ids}, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION], ids=[difficulty])) 220 | else: 221 | filters.append(Filter(tags={'#e': ids}, 222 | kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION])) # event responses 223 | self.filters = Filters(filters) 224 | 225 | 226 | class SubscribeFeed(Subscribe): 227 | def __init__(self, name, relay, batch, ids, since=None): 228 | super().__init__(name, relay, batch) 229 | self.ids = ids 230 | self.since = since 231 | self.build_filters() 232 | self.send() 233 | 234 | def build_filters(self): 235 | logger.info('build subscription filters') 236 | if self.since is None: 237 | self.filters = Filters([ 238 | Filter(tags={'#e': self.ids}, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION]), 239 | # event responses 240 | Filter(ids=self.ids, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION]) 241 | ]) 242 | else: 243 | self.filters = Filters([ 244 | Filter(tags={'#e': self.ids}, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION], since=self.since), 245 | Filter(ids=self.ids, kinds=[EventKind.TEXT_NOTE, EventKind.BOOST, EventKind.REACTION], since=self.since) 246 | ]) 247 | 248 | 249 | 250 | class SubscribeMessages(Subscribe): 251 | def __init__(self, name, relay, batch, pubkey, since): 252 | super().__init__(name, relay, batch) 253 | self.pubkey = pubkey 254 | self.since = since 255 | self.build_filters() 256 | self.send() 257 | 258 | def build_filters(self): 259 | logger.info('build subscription filters') 260 | self.filters = Filters([ 261 | Filter(authors=[self.pubkey], kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], since=self.since), 262 | Filter(tags={'#p': [self.pubkey]}, kinds=[EventKind.ENCRYPTED_DIRECT_MESSAGE], since=self.since) 263 | ]) 264 | --------------------------------------------------------------------------------