Compare commits

...

10 Commits

Author SHA1 Message Date
Timofey Parrrate
f12889ccdf Add 'LICENSE' 2021-12-22 10:32:26 +00:00
da1d9a23d6 readme
and some untested `exact` and `flag` code also
2021-12-22 13:30:05 +03:00
5f49cd7f46 typing 2020-11-08 17:42:40 +03:00
41b1579785 !return code 2020-08-23 00:11:20 +03:00
c9d8be1a24 push vibrate 2020-08-23 00:09:52 +03:00
f008ed2462 run encoding 2020-08-23 00:06:16 +03:00
ee4a6865d8 popen -> run 2020-08-23 00:03:46 +03:00
ab311c54d8 push x 2020-08-22 23:19:22 +03:00
57fc83567d staging 2020-08-19 00:16:01 +03:00
502afe9827 rc1 2020-08-16 17:32:16 +03:00
18 changed files with 197 additions and 98 deletions

3
.gitignore vendored
View File

@ -206,3 +206,6 @@ fabric.properties
# Others
*.db
/v25pushx/
/report.sql
/vapidkeys.json

View File

@ -2,6 +2,7 @@
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/v25pushx" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />

View File

@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/v25pushx" vcs="Git" />
</component>
</project>

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2021 PARRRATE T&V
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,2 +1,3 @@
# v25
replace public keys in `dev-config.json` and `staging-config.json` with your own to allow messaging

View File

@ -1,7 +1,7 @@
from flask import Flask
from werkzeug.middleware.dispatcher import DispatcherMiddleware
import config
from v25 import config
from v25.storage.dbstorage import DBStorage
from v25.web.server.api import API
@ -11,7 +11,7 @@ def simple(_env, resp):
return []
d = config.get_config()
d = config.get_config('dev-config.json')
config.from_config(d)
app = Flask(__name__)

View File

@ -2,7 +2,7 @@ from setuptools import setup
setup(
name='v25',
version='0.0.1-a1',
version='0.0.1-rc1',
packages=['v25', 'v25.web', 'v25.web.client', 'v25.web.server', 'v25.storage', 'v25.messaging'],
url='',
license='',

17
staging-config.json Normal file
View File

@ -0,0 +1,17 @@
{
"db": "sqlite:///staging.db",
"subjects": {
"ro6ncuJxA_cGQ51hPKw11Q84of08j7GtOjL0Xr5GaFs=": {
"allowed": null,
"contacts": null
},
"oNNNAvX5nsJEQGf33xulhh27cpECgQtJT3jzu2VyNKY=": {
"allowed": null,
"contacts": null
},
"uLep1UFgMlYDaIM8MEgMYTDY6HWcUq6Y4VvkyglbGJ8=": {
"allowed": null,
"contacts": null
}
}
}

22
staging-main.py Normal file
View File

@ -0,0 +1,22 @@
from flask import Flask
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from v25 import config
from v25.storage.dbstorage import DBStorage
from v25.web.server.api import API
def simple(_env, resp):
resp('404 OK', [])
return []
d = config.get_config('staging-config.json')
config.from_config(d)
app = Flask(__name__)
app.wsgi_app = DispatcherMiddleware(simple, {
'/v25': API(__name__, DBStorage(d['db']).pushing())
})
app.config['ENV'] = 'staging'
app.run(port=5013)

View File

@ -4,11 +4,11 @@ from typing import Dict, Any, Union
from v25.storage.dbstorage import DBStorage
_d_type = Dict[Any, Union[str, Dict[str, Any]]]
_d_type = Dict[str, Union[str, Dict[str, Any]]]
def get_config() -> _d_type:
with open('config.json') as f:
def get_config(file: str) -> _d_type:
with open(file) as f:
return json.load(f)
@ -17,11 +17,3 @@ def from_config(d: _d_type):
subjects = d["subjects"]
for subject in subjects:
storage.ssssj(subject, json.dumps(subjects[subject]))
def main():
from_config(get_config())
if __name__ == '__main__':
main()

View File

@ -1,6 +1,6 @@
__all__ = ('Flags',)
import nacl.hash
Q_FLAG = '<?>'
__all__ = ('Flags',)
class Flags:
@ -9,14 +9,17 @@ class Flags:
def __init__(self, flags: str):
self.flags: str = flags
def quable(self) -> bool:
return Q_FLAG in self.flags
def hash(self):
return nacl.hash.sha256(self.flags.encode()).decode()
def deq(self) -> str:
return Flags(self.flags.replace(Q_FLAG, '')).deq() if self.quable() else self.flags
def joint(self, flags: str):
return flags.startswith(self.hash())
def enq(self) -> str:
return self.flags if self.quable() else self.flags + Q_FLAG
def join(self, flags: str):
h = self.hash()
if flags.startswith(h):
return flags
return h + flags
Flags.default = Flags('<unedited>').enq()
Flags.default = '<unedited>'

View File

@ -95,7 +95,7 @@ class Message:
def edit(self, pcontent: bytes) -> 'Message':
return Message(self.sfrom, self.sto, self.idnonce, None,
Encoding.nonce(), pcontent, None,
Flags(self.flags.replace('<unedited>', '<edited>')).enq()).sealed()
self.flags.replace('<unedited>', '<edited>')).sealed()
def edit_(self):
return self.flags_(self.flags)

View File

@ -1,10 +1,11 @@
import json
from contextlib import closing
from subprocess import run
from sys import stderr
from threading import Thread
from time import time, sleep
from typing import Tuple, Optional, Iterable, List
import requests
from nacl.bindings import crypto_sign_PUBLICKEYBYTES
from sqlalchemy import create_engine, LargeBinary, Column, REAL, BLOB, String, or_, and_, ForeignKeyConstraint, \
Integer, UniqueConstraint, Index, Boolean
@ -145,6 +146,43 @@ class PushState(Base):
return True
class TypingState(Base):
__tablename__ = 'typingstate'
sf = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
st = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
last = Column(REAL)
@classmethod
def _get(cls, sfrom: Subject, sto: Subject, session) -> Tuple['TypingState', 'TypingState']:
sf = sfrom.vkey.encode()
st = sto.vkey.encode()
try:
statef = session.query(TypingState).filter_by(sf=sf, st=st).one()
except NoResultFound:
statef = TypingState(sf=sf, st=st, last=0)
session.add(statef)
try:
statet = session.query(TypingState).filter_by(sf=st, st=sf).one()
except NoResultFound:
statet = TypingState(sf=st, st=sf, last=0)
session.add(statet)
return statef, statet
@classmethod
def typing(cls, sfrom: Subject, sto: Subject, last: float, session) -> float:
statef, statet = cls._get(sfrom, sto, session)
statef.last = max(statef.last, last)
session.commit()
return statet.last
@classmethod
def reset(cls, m: Message, session):
statef, _ = cls._get(m.sfrom, m.sto, session)
statef.last = 0
session.commit()
class DBStorage(PushStorage):
def __init__(self, *args):
self.args = args
@ -153,8 +191,7 @@ class DBStorage(PushStorage):
self.Session = sessionmaker(bind=self.engine)
def check(self, subject: Subject) -> dict:
session = self.Session()
try:
with closing(self.Session()) as session:
sssj = session.query(SSSJ).filter_by(subject=subject.vkey.encode()).one_or_none()
status = json.loads(sssj.status) if sssj else {}
if 'contacts' in status:
@ -169,29 +206,23 @@ class DBStorage(PushStorage):
contacts.add(st)
status['contacts'] = list(map(Encoding.encode, contacts))
return status
finally:
session.close()
def push(self, m: Message) -> None:
session = self.Session()
try:
with closing(self.Session()) as session:
msg = Msg.from_message(m)
session.add(msg)
session.add(msg.sgn())
self.event(session, m, EVENT_PUSH)
session.commit()
if m.sfrom == m.sto:
return
state = PushState.of(m.sto, session)
state.pushmsg = True
session.commit()
finally:
session.close()
if m.sfrom != m.sto:
state = PushState.of(m.sto, session)
state.pushmsg = True
session.commit()
TypingState.reset(m, session)
def edit(self, old: Message, new: Message):
assert old.edited(new), 'edit misuse'
session = self.Session()
try:
with closing(self.Session()) as session:
msg = self.one_alike(session, old)
assert msg.en == old.editnonce, 'edit misuse'
msgn = Msg.from_message(new)
@ -200,27 +231,22 @@ class DBStorage(PushStorage):
msg.flags = msgn.flags
self.event(session, new, EVENT_EDIT)
session.commit()
finally:
session.close()
TypingState.reset(new, session)
@staticmethod
def one_alike(session, m: Message) -> Msg:
return session.query(Msg).filter_by(sf=m.sfrom.vkey.encode(), st=m.sto.vkey.encode(), idn=m.idnonce).one()
def delete(self, m: Message):
session = self.Session()
try:
with closing(self.Session()) as session:
session.delete(self.one_alike(session, m))
self.event(session, m, EVENT_DELETE)
session.commit()
finally:
session.close()
def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
if params is None:
params = {}
session = self.Session()
try:
with closing(self.Session()) as session:
cquery: Query = session.query(Msg).filter(or_(
and_(
Msg.sf == pair[0].vkey.encode(),
@ -232,56 +258,51 @@ class DBStorage(PushStorage):
),
))
query: Query = cquery
if 'ts' in params:
if '>' in params:
query = query.filter(Msg.ts > params['ts']['>'])
if '<' in params:
query = query.filter(Msg.ts < params['ts']['<'])
if params.get('before'):
query = query.filter(Msg.oid < cquery.filter(Msg.idn == Encoding.decode(params['before'])).one().oid)
if params.get('after'):
query = query.filter(Msg.oid > cquery.filter(Msg.idn == Encoding.decode(params['after'])).one().oid)
if params.get('exact'):
query = query.filter_by(idn=Encoding.decode(params['exact']))
query = query.filter_by(idn=Encoding.decode(params['exact']), sf=pair[0].vkey.encode())
for flag in params.get('flags', ()):
query = query.filter(Msg.flags.contains(flag))
query = query.order_by(Msg.oid.desc())
if 'limit' in params:
query = query.limit(params['limit'])
return map(Msg.to_message, list(query.from_self().order_by(Msg.oid)))
finally:
session.close()
res = map(Msg.to_message, list(query.from_self().order_by(Msg.oid)))
if 'edit_' in params:
res = (m.edit_() for m in res)
return res
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
with closing(self.Session()) as session:
query: Query = session.query(Msg)
query = query.filter_by(sf=sfrom, st=sto, idn=idnonce)
if editnonce:
query = query.filter_by(en=editnonce)
msg: Optional[Msg] = query.one_or_none()
return msg or msg.to_message()
def flags(self, m: Message, flags: str):
assert not Flags(flags).quable(), 'flags misuse'
session = self.Session()
try:
with closing(self.Session()) as session:
msg: Msg = self.one_alike(session, m)
assert msg.en == m.editnonce, 'flags misuse'
assert Flags(msg.flags).quable(), 'flags misuse'
assert Flags(msg.flags).joint(flags)
msg.flags = flags
session.commit()
finally:
session.close()
def clearsssj(self):
session = self.Session()
try:
with closing(self.Session()) as session:
session.query(SSSJ).delete()
session.commit()
finally:
session.close()
def ssssj(self, subject: str, status: str):
"""set SSSJ"""
session = self.Session()
try:
with closing(self.Session()) as session:
subject = Subject(Encoding.decode(subject)).vkey.encode()
session.query(SSSJ).filter_by(subject=subject).delete()
session.add(SSSJ(subject=subject, status=status))
session.commit()
finally:
session.close()
@staticmethod
def event(session, m: Message, code: int):
@ -294,8 +315,7 @@ class DBStorage(PushStorage):
return query.filter_by(en=en).one()
def events(self, sfrom: Subject, sto: Subject, after: Optional[bytes]) -> Iterable[Tuple[bytes, bytes]]:
session = self.Session()
try:
with closing(self.Session()) as session:
PushState.of(sto, session).online()
session.commit()
query: Query = session.query(MsgEvent)
@ -308,67 +328,51 @@ class DBStorage(PushStorage):
query = query.order_by(MsgEvent.oid)
ev: MsgEvent
return [(ev.en, ev.idn) for ev in query.all()]
finally:
session.close()
def cleanevents(self):
session = self.Session()
try:
with closing(self.Session()) as session:
session.query(MsgEvent).delete(MsgEvent.ts < time() - 604800)
session.commit()
finally:
session.close()
def subscribe(self, subject: Subject, subscription: dict):
session = self.Session()
try:
with closing(self.Session()) as session:
subject = subject.vkey.encode()
session.query(PushSub).filter_by(subject=subject).delete()
session.add(PushSub(subject=subject, subscription=json.dumps(subscription)))
session.commit()
finally:
session.close()
def subscription(self, subject: Subject) -> Optional[dict]:
session = self.Session()
try:
with closing(self.Session()) as session:
subscription: Optional[PushSub] = session.query(PushSub).filter_by(
subject=subject.vkey.encode()).one_or_none()
if subscription is None:
return None
return json.loads(subscription.subscription)
finally:
session.close()
@staticmethod
def pushsubscription(subscription: dict):
assert requests.post('http://localhost:5025/api/push', json={
assert not run(["node", "v25pushx/push.js"], input=json.dumps({
"subscription": subscription,
"notification": {
"notification": {
"title": "New Message (V25PUSH)"
"title": "New Message (V25PUSH)",
"vibrate": [100, 50, 100],
}
}
}).status_code == 200, "push failed"
}), encoding='utf-8').returncode, "push subprocess failed"
def pushpush(self, subject: Subject):
session = self.Session()
try:
with closing(self.Session()) as session:
notify = PushState.of(subject, session).notify()
session.commit()
finally:
session.close()
if notify:
subscription = self.subscription(subject)
if subscription:
self.pushsubscription(subscription)
def push_once(self):
session = self.Session()
try:
with closing(self.Session()) as session:
subs: List[PushSub] = session.query(PushSub).all()
finally:
session.close()
for sub in subs:
try:
self.pushpush(Subject(sub.subject))
@ -390,3 +394,7 @@ class DBStorage(PushStorage):
def pushing(self):
self.push_forever()
return self
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
with closing(self.Session()) as session:
return TypingState.typing(sfrom, sto, last, session)

View File

@ -33,6 +33,10 @@ class SecureStorage(PushStorage):
assert self.subject in pair, self.asrt()
return self.storage.pull(pair, params)
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
assert self.subject in (sfrom, sto)
return self.storage.exact(sfrom, sto, idnonce, editnonce)
def flags(self, m: Message, flags: str):
assert self.subject in m.pair, self.asrt()
return self.storage.flags(m, flags)
@ -44,3 +48,7 @@ class SecureStorage(PushStorage):
def subscribe(self, subject: Subject, subscription: dict):
assert self.subject == subject, self.asrt()
return self.storage.subscribe(subject, subscription)
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
assert self.subject == sfrom, self.asrt()
return self.storage.typing(sfrom, sto, last)

View File

@ -21,6 +21,9 @@ class AbstractStorage(ABC):
def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
raise NotImplementedError
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
raise NotImplementedError
def flags(self, m: Message, flags: str):
raise NotImplementedError
@ -29,6 +32,9 @@ class EventStorage(AbstractStorage, ABC):
def events(self, sfrom: Subject, sto: Subject, after: Optional[bytes]) -> Iterable[Tuple[bytes, bytes]]:
raise NotImplementedError
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
raise NotImplementedError
EVENT_PUSH = 0
EVENT_EDIT = 1

View File

@ -42,6 +42,9 @@ class RemoteStorage(PushStorage):
return map(Message.loads,
self.req('pull', {'params': params, 'pair': [subject.dumps() for subject in pair]}))
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
raise NotImplementedError
def flags(self, m: Message, flags: str):
self.req('flags', {'m': m.dumps(), 'flags': flags})
@ -50,6 +53,13 @@ class RemoteStorage(PushStorage):
self.req('events', {'sfrom': sfrom.dumps(), 'sto': sto.dumps(),
'after': Encoding.encode(after)})]
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
return self.req('typing', {
'sfrom': sfrom.dumps(),
'sto': sto.dumps(),
'last': last
})
def subscribe(self, subject: Subject, subscription: dict):
self.req('subscribe', {
'subject': subject.dumps(),

View File

@ -80,3 +80,11 @@ class API(Flask):
def subscribe():
return self.nomessassertcall(lambda d, storage: storage.subscribe(Subject.loads(d['subject']),
d['subscription']))
@app.route('/typing', methods=['POST'])
def typing():
return self.nomessassertcall(lambda d, storage: storage.typing(
Subject.loads(d['sfrom']),
Subject.loads(d['sto']),
d['last']
))