├── .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 |
2 |
3 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/bija/templates/lists.list.html:
--------------------------------------------------------------------------------
1 |
2 | {%- for item in lists -%}
3 | {%- if id == item.id -%}
4 | {%- set class = 'actv' -%}
5 | {%- endif -%}
6 | {{item.name}}
7 | {%- endfor -%}
8 |
--------------------------------------------------------------------------------
/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 |
9 | {%- for alert in alerts: -%}
10 |
11 |
12 | {{ alert['kind']| alert(alert['data'])|safe }}
13 |
14 | {%- endfor -%}
15 |
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 |
3 | {%- for item in topics -%}
4 | {%- if item.tag == topic -%}
5 | {%- set class = 'actv' -%}
6 | {%- else -%}
7 | {%- set class = '' -%}
8 | {%- endif -%}
9 | #{{item.tag}}
10 | {%- endfor -%}
11 |
--------------------------------------------------------------------------------
/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 | Public {{k['public']}}
13 |
14 |
15 | Private {{k['private']}}
16 |
17 |
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 | {{n_members}} Members {{'edit'| svg_icon('icon-sm')|safe}}
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 |
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 | Show me
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 | Move to inbox
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 |
Show QR
18 |
19 |
--------------------------------------------------------------------------------
/bija/templates/list.members.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {%- for profile in profiles: -%}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {%- set p=profile -%}
13 | {%- include 'profile/profile.image.html' -%}
14 |
15 |
19 |
20 |
21 |
22 | {%- endfor -%}
23 |
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 |
{{'unfollow'| svg_icon('icon')|safe}} unfollow
15 | {%- else: -%}
16 |
{{'follow'| svg_icon('icon')|safe}} follow
17 | {%- endif -%}
18 |
{{'block'| svg_icon('icon warn')|safe}} block
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 ] unblock
10 | {%- endif -%}
11 | Public posts
12 |
13 |
14 | {%- include 'feed/feed.items.html' -%}
15 |
16 |
17 |
18 |
Fetch older posts:
19 |
20 | -- Select timeframe --
21 | 1 week
22 | 1 month
23 | 1 year
24 | All time
25 |
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 |
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 |
13 | {{'reshare'| svg_icon('icon')|safe}}
14 |
15 | {{'reshare'| svg_icon('icon-sm')|safe}} Boost
16 | {{'edit'| svg_icon('icon-sm')|safe}} Quote
17 |
18 |
19 | {{note['shares'] if note['shares']}}
20 |
21 |
22 | {{liked_im| svg_icon('icon '+liked_im)|safe}}
23 | {{note['likes'] if note['likes']}}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/bija/templates/note.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 Mark all read
9 | {%- else -%}
10 | {{'messages'| svg_icon('icon-lg')|safe}} Junk Empty
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 |
23 |
24 | {%- with p=message -%}
25 | {%- include 'profile/profile.image.html' -%}
26 | {%- endwith -%}
27 | {%- if message.n>0 -%}
28 | {{message.n}}
29 | {%- endif -%}
30 |
31 |
36 |
37 |
38 |
39 | {%- endfor -%}
40 |
41 |
Fetch older messages:
42 |
43 | -- Select timeframe --
44 | 1 week
45 | 1 month
46 | 1 year
47 | All time
48 |
49 |
50 |
51 |
Found new notes
52 |
53 |
54 | {%- endblock content -%}
55 |
56 | {%- block right_content -%}
57 |
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 |
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 |
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 |
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 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Search for a person: @name/@pubkey
27 | Or by a Nip-05 identity: handle@mysite.web
28 | Search by topic: #hashtag
29 |
30 |
31 |
32 |
33 | {{'menu'| svg_icon('icon-lg')|safe}}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
52 |
53 | {%- block left_content -%}
54 | {%- endblock left_content -%}
55 |
56 |
57 | {%- block content -%}
58 | {%- endblock content -%}
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | Search for a person: @name/@pubkey
68 | Or by a Nip-05 identity: handle@mysite.web
69 | Search by topic: #hashtag
70 |
71 |
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 |
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 | {{relay}}
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 Address topliquor87@walletofsatoshi.com
82 |
On Chain bc1qawh3jreepchfw5nmfm9qpxdyla4dx0kynfnv27
83 |
84 |
Feel free to follow me on Nostr:
85 |
Nip-05 CarlosAutonomous@rebelweb.co.uk
86 |
npub npub1qqqqqqqut3z3jeuxu70c85slaqq4f87unr3vymukmnhsdzjahntsfmctgs
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 |
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 |
12 | {%- for t in themes: -%}
13 | {%- if t.theme == theme -%}
14 | {{t.theme}}
15 | {%- else -%}
16 | {{t.theme}}
17 | {%- endif -%}
18 | {%- endfor -%}
19 |
20 |
21 |
22 |
23 |
24 |
Other style settings
25 |
26 |
27 | Font size
28 |
29 | Small text
30 | Medium text
31 | Large text
32 |
33 |
34 |
35 | Rounded corners
36 |
37 |
Small
38 |
Medium
39 |
Large
40 |
41 |
42 |
43 | Spacing
44 |
45 | Small
46 | Medium
47 | Large
48 |
49 |
50 |
51 | Icon size
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 | Profile image size
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 | Save Reset to defaults
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 |
Click to reveal
89 |
90 |
91 |
92 |
93 |
94 |
Relays
95 |
96 | {%- include 'relays.list.html' -%}
97 |
98 |
reset connections
99 |
100 |
101 |
124 |
125 |
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 |
--------------------------------------------------------------------------------