Merge remote-tracking branch 'origin/master'

# Conflicts:
#	tests/server_test.py
This commit is contained in:
Oleg Silkin 2019-07-21 20:10:15 -04:00
commit ece988ae36
14 changed files with 419 additions and 249 deletions

119
README.md
View file

@ -1,49 +1,116 @@
# LBRY Comment Server v2 # LBRY Comment Server
This is a rewrite & update of the server This is the code for the LBRY Comment Server.
[written by myself and Grayson Burton here](https://github.com/ocornoc/lbry-comments) Fork it, run it, set it on fire. Up to you.
### Install & Setup
Clone the repo and install a virtual environement: ## Prerequisites
In order to run the comment server properly, you will need the
following:
0. A Unix Compliant Operating System (e.g: Ubuntu, RedHat, Hannah Montana, etc.)
1. Any recent version of Sqlite3
2. Python 3.6+ (including `python3-dev`, `python3-virtualenv`, `python3-pip`)
Note: You must specify the specific python version you're using,
e.g. for Python3.6, you would install `python36-dev`. You're smart enough to figure the rest out from here ;)
3. (Optional) Reverse Proxy software to handle a public-facing API.
We recommend Caddy, though there is an `nginx.conf` file under `config`.
4. Patience (Strongly recommended but often neglected)
## Installation
Installing the server:
```bash ```bash
# clone the repo
$ git clone https://github.com/osilkin98/comment-server $ git clone https://github.com/osilkin98/comment-server
$ cd comment-server
# create a virtual environment in any (current) version of python3.X # create a virtual environment
$ virtualenv --python=python3 venv $ virtualenv --python=python3 venv
$ source venv/bin/activate
# install the dependencies # Enter the virtual environment
$ source venv/bin/activate
# install the library dependencies
(venv) $ pip install -r requirements.txt (venv) $ pip install -r requirements.txt
``` ```
### Running the server ## Usage
Just run: ### Running the Server
`(venv) $ python -m src.main.py` To start the server, simply run:
and it should run automatically. ```bash
# to enter server's venv
$ source venv/bin/activate
# To start server as daemon process
(venv) $ python -m main &
```
### Testing
To Test the database, simply run:
```bash
# To run the whole thing :
(venv) $ python -m unittest tests.database
# To run a specific TestName under a specified TestClass:
(venv) $ python -m unittest tests.database.TestClass.TestName`
```
There are basic tests to run against the server, though they require
that there is a server instance running, though the database
chosen may have to be edited in `config/conf.json`.
Additionally there are HTTP requests that can be send with whatever
software you choose to test the integrity of the comment server.
## Schema ## Schema
![schema](schema.png) ![schema](schema.png)
## About ## Contributing
A lot of the design is more or less the same with the original, Contributions are welcome, verbosity is encouraged. Please be considerate
except this version focuses less on performance and more on scalability. in your posts, and make sure that you give as much context to the issue
as possible, so that helping you is a slam dunk for us.
Rewritten with python because it's easier to adjust ### Issues
and maintain. Instead of doing any multithreading, If you spotted an issue from the SDK side, please replicate it using
this implementation just delegates a single `curl` and one of the HTTP request templates in `tests/http_requests`.
database connection for write operations.
The server was originally implemented with `aiohttp` Then, just include that along with the rest of your information.
and uses `aiojobs` for scheduling write operations.
As pointed out by several people, Python is a dinosaur ### Pull Requests
in comparison to SQLite's execution speed, Make sure the code works and has been tested beforehand.
so there is no sensibility in multi-threading from the Although we love helping out, our job is to review your code,
perspective of the server code itself. not test it - that's what your computer is for.
Try to document the changes you made in a human language,
preferably English. (but we're always up for a challenge...)
Use the level of verbosity you feel is correct, and when in doubt,
just [KISS](https://people.apache.org/~fhanik/kiss.html).
### General
For more details, please refer to [lbry.tech/contribute](https://lbry.tech/contribute).
## License
This project is licensed by AGPLv3.
See [LICENSE](LICENSE.nd) for the full license.
## Security
We take security seriously.
Please contact [security@lbry.io](security@lbry.io) regarding any conerns you might have,
issues you might encounter, or general outlooks on life. Our PGP key can
be found [here](https://keybase.io/lbry/key.asc), should you need it.
## Contact Us
The primary contact for this project is
[@osilkin98](https://github.com/osilkin98), and can be reached
at (o.silkin98@gmail.com).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS COMMENT (
ParentId TEXT DEFAULT NULL, ParentId TEXT DEFAULT NULL,
Signature TEXT DEFAULT NULL, Signature TEXT DEFAULT NULL,
Timestamp INTEGER NOT NULL, Timestamp INTEGER NOT NULL,
SigningTs TEXT DEFAULT NULL,
CONSTRAINT COMMENT_PRIMARY_KEY PRIMARY KEY (CommentId) ON CONFLICT IGNORE, CONSTRAINT COMMENT_PRIMARY_KEY PRIMARY KEY (CommentId) ON CONFLICT IGNORE,
CONSTRAINT COMMENT_SIGNATURE_SK UNIQUE (Signature) ON CONFLICT ABORT, CONSTRAINT COMMENT_SIGNATURE_SK UNIQUE (Signature) ON CONFLICT ABORT,
CONSTRAINT COMMENT_CHANNEL_FK FOREIGN KEY (ChannelId) REFERENCES CHANNEL(ClaimId) CONSTRAINT COMMENT_CHANNEL_FK FOREIGN KEY (ChannelId) REFERENCES CHANNEL(ClaimId)
@ -22,6 +23,8 @@ CREATE TABLE IF NOT EXISTS COMMENT (
ON UPDATE CASCADE ON DELETE NO ACTION -- setting null implies comment is top level ON UPDATE CASCADE ON DELETE NO ACTION -- setting null implies comment is top level
); );
-- ALTER TABLE COMMENT ADD COLUMN SigningTs TEXT DEFAULT NULL;
-- DROP TABLE IF EXISTS CHANNEL; -- DROP TABLE IF EXISTS CHANNEL;
CREATE TABLE IF NOT EXISTS CHANNEL( CREATE TABLE IF NOT EXISTS CHANNEL(
ClaimId TEXT NOT NULL, ClaimId TEXT NOT NULL,
@ -38,8 +41,8 @@ CREATE INDEX IF NOT EXISTS COMMENT_CLAIM_INDEX ON COMMENT (LbryClaimId);
-- VIEWS -- VIEWS
DROP VIEW IF EXISTS COMMENTS_ON_CLAIMS; DROP VIEW IF EXISTS COMMENTS_ON_CLAIMS;
CREATE VIEW IF NOT EXISTS COMMENTS_ON_CLAIMS (comment_id, claim_id, timestamp, channel_name, channel_id, channel_url, signature, parent_id, comment) AS CREATE VIEW IF NOT EXISTS COMMENTS_ON_CLAIMS (comment_id, claim_id, timestamp, channel_name, channel_id, channel_url, signature, signing_ts, parent_id, comment) AS
SELECT C.CommentId, C.LbryClaimId, C.Timestamp, CHAN.Name, CHAN.ClaimId, 'lbry://' || CHAN.Name || '#' || CHAN.ClaimId, C.Signature, C.ParentId, C.Body SELECT C.CommentId, C.LbryClaimId, C.Timestamp, CHAN.Name, CHAN.ClaimId, 'lbry://' || CHAN.Name || '#' || CHAN.ClaimId, C.Signature, C.SigningTs, C.ParentId, C.Body
FROM COMMENT AS C FROM COMMENT AS C
LEFT OUTER JOIN CHANNEL CHAN on C.ChannelId = CHAN.ClaimId LEFT OUTER JOIN CHANNEL CHAN on C.ChannelId = CHAN.ClaimId
ORDER BY C.Timestamp DESC; ORDER BY C.Timestamp DESC;

View file

@ -8,11 +8,10 @@ import asyncio
from aiohttp import web from aiohttp import web
import schema.db_helpers import schema.db_helpers
from src.database import obtain_connection from src.database import obtain_connection, DatabaseWriter
from src.handles import api_endpoint from src.handles import api_endpoint
from src.handles import create_comment_scheduler from src.handles import create_comment_scheduler
from src.settings import config_path, get_config from src.settings import config_path, get_config
from src.writes import DatabaseWriter
config = get_config(config_path) config = get_config(config_path)
@ -50,21 +49,20 @@ async def close_comment_scheduler(app):
await app['comment_scheduler'].close() await app['comment_scheduler'].close()
async def create_database_backup(app): async def database_backup_routine(app):
try: try:
while True: while True:
await asyncio.sleep(app['config']['BACKUP_INT']) await asyncio.sleep(app['config']['BACKUP_INT'])
with obtain_connection(app['db_path']) as conn: with obtain_connection(app['db_path']) as conn:
logger.debug('backing up database') logger.debug('backing up database')
schema.db_helpers.backup_database(conn, app['backup']) schema.db_helpers.backup_database(conn, app['backup'])
except asyncio.CancelledError:
except asyncio.CancelledError as e:
pass pass
async def start_background_tasks(app: web.Application): async def start_background_tasks(app: web.Application):
app['reader'] = obtain_connection(app['db_path'], True) app['reader'] = obtain_connection(app['db_path'], True)
app['waitful_backup'] = app.loop.create_task(create_database_backup(app)) app['waitful_backup'] = app.loop.create_task(database_backup_routine(app))
app['comment_scheduler'] = await create_comment_scheduler() app['comment_scheduler'] = await create_comment_scheduler()
app['writer'] = DatabaseWriter(app['db_path']) app['writer'] = DatabaseWriter(app['db_path'])

View file

@ -1,5 +1,5 @@
import atexit
import logging import logging
import re
import sqlite3 import sqlite3
import time import time
import typing import typing
@ -23,78 +23,66 @@ def obtain_connection(filepath: str = None, row_factory: bool = True):
def get_claim_comments(conn: sqlite3.Connection, claim_id: str, parent_id: str = None, def get_claim_comments(conn: sqlite3.Connection, claim_id: str, parent_id: str = None,
page: int = 1, page_size: int = 50, top_level=False): page: int = 1, page_size: int = 50, top_level=False):
if top_level: with conn:
results = [clean(dict(row)) for row in conn.execute( if top_level:
""" SELECT comment, comment_id, channel_name, channel_id, channel_url, timestamp, signature, parent_id results = [clean(dict(row)) for row in conn.execute(
FROM COMMENTS_ON_CLAIMS """ SELECT comment, comment_id, channel_name, channel_id,
channel_url, timestamp, signature, signing_ts, parent_id
FROM COMMENTS_ON_CLAIMS
WHERE claim_id LIKE ? AND parent_id IS NULL
LIMIT ? OFFSET ? """,
(claim_id, page_size, page_size*(page - 1))
)]
count = conn.execute(
"""
SELECT COUNT(*)
FROM COMMENTS_ON_CLAIMS
WHERE claim_id LIKE ? AND parent_id IS NULL WHERE claim_id LIKE ? AND parent_id IS NULL
LIMIT ? OFFSET ? """, """, (claim_id, )
(claim_id, page_size, page_size*(page - 1)) )
)] elif parent_id is None:
count = conn.execute( results = [clean(dict(row)) for row in conn.execute(
""" """ SELECT comment, comment_id, channel_name, channel_id,
SELECT COUNT(*) channel_url, timestamp, signature, signing_ts, parent_id
FROM COMMENTS_ON_CLAIMS FROM COMMENTS_ON_CLAIMS
WHERE claim_id LIKE ? AND parent_id IS NULL WHERE claim_id LIKE ?
""", (claim_id, ) LIMIT ? OFFSET ? """,
) (claim_id, page_size, page_size*(page - 1))
elif parent_id is None: )]
results = [clean(dict(row)) for row in conn.execute( count = conn.execute(
""" SELECT comment, comment_id, channel_name, channel_id, channel_url, timestamp, signature, parent_id """
FROM COMMENTS_ON_CLAIMS SELECT COUNT(*)
WHERE claim_id LIKE ? FROM COMMENTS_ON_CLAIMS
LIMIT ? OFFSET ? """, WHERE claim_id LIKE ?
(claim_id, page_size, page_size*(page - 1)) """, (claim_id,)
)] )
count = conn.execute( else:
""" results = [clean(dict(row)) for row in conn.execute(
SELECT COUNT(*) """ SELECT comment, comment_id, channel_name, channel_id,
FROM COMMENTS_ON_CLAIMS channel_url, timestamp, signature, signing_ts, parent_id
WHERE claim_id LIKE ? FROM COMMENTS_ON_CLAIMS
""", (claim_id,) WHERE claim_id LIKE ? AND parent_id = ?
) LIMIT ? OFFSET ? """,
else: (claim_id, parent_id, page_size, page_size*(page - 1))
results = [clean(dict(row)) for row in conn.execute( )]
""" SELECT comment, comment_id, channel_name, channel_id, channel_url, timestamp, signature, parent_id count = conn.execute(
FROM COMMENTS_ON_CLAIMS """
WHERE claim_id LIKE ? AND parent_id = ? SELECT COUNT(*)
LIMIT ? OFFSET ? """, FROM COMMENTS_ON_CLAIMS
(claim_id, parent_id, page_size, page_size*(page - 1)) WHERE claim_id LIKE ? AND parent_id = ?
)] """, (claim_id, parent_id)
count = conn.execute( )
""" count = tuple(count.fetchone())[0]
SELECT COUNT(*) return {
FROM COMMENTS_ON_CLAIMS 'items': results,
WHERE claim_id LIKE ? AND parent_id = ? 'page': page,
""", (claim_id, parent_id) 'page_size': page_size,
) 'total_pages': math.ceil(count/page_size),
count = tuple(count.fetchone())[0] 'total_items': count
return { }
'items': results,
'page': page,
'page_size': page_size,
'total_pages': math.ceil(count/page_size),
'total_items': count
}
def validate_input(**kwargs): def insert_channel(conn: sqlite3.Connection, channel_name: str, channel_id: str):
assert 0 < len(kwargs['comment']) <= 2000
assert re.fullmatch(
'[a-z0-9]{40}:([a-z0-9]{40})?',
kwargs['claim_id'] + ':' + kwargs.get('channel_id', '')
)
if 'channel_name' in kwargs or 'channel_id' in kwargs:
assert 'channel_id' in kwargs and 'channel_name' in kwargs
assert re.fullmatch(
'^@(?:(?![\x00-\x08\x0b\x0c\x0e-\x1f\x23-\x26'
'\x2f\x3a\x3d\x3f-\x40\uFFFE-\U0000FFFF]).){1,255}$',
kwargs.get('channel_name', '')
)
assert re.fullmatch('[a-z0-9]{40}', kwargs.get('channel_id', ''))
def _insert_channel(conn: sqlite3.Connection, channel_name: str, channel_id: str):
with conn: with conn:
conn.execute( conn.execute(
'INSERT INTO CHANNEL(ClaimId, Name) VALUES (?, ?)', 'INSERT INTO CHANNEL(ClaimId, Name) VALUES (?, ?)',
@ -102,59 +90,35 @@ def _insert_channel(conn: sqlite3.Connection, channel_name: str, channel_id: str
) )
def _insert_comment(conn: sqlite3.Connection, claim_id: str = None, comment: str = None, def insert_comment(conn: sqlite3.Connection, claim_id: str = None, comment: str = None,
channel_id: str = None, signature: str = None, parent_id: str = None) -> str: channel_id: str = None, signature: str = None, signing_ts: str = None,
parent_id: str = None) -> str:
timestamp = int(time.time()) timestamp = int(time.time())
comment_prehash = ':'.join((claim_id, comment, str(timestamp),)) prehash = b':'.join((claim_id.encode(), comment.encode(), str(timestamp).encode(),))
comment_prehash = bytes(comment_prehash.encode('utf-8')) comment_id = nacl.hash.sha256(prehash).decode()
comment_id = nacl.hash.sha256(comment_prehash).decode('utf-8')
with conn: with conn:
conn.execute( conn.execute(
""" """
INSERT INTO COMMENT(CommentId, LbryClaimId, ChannelId, Body, INSERT INTO COMMENT(CommentId, LbryClaimId, ChannelId, Body, ParentId, Timestamp, Signature, SigningTs)
ParentId, Signature, Timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(comment_id, claim_id, channel_id, comment, parent_id, signature, timestamp) (comment_id, claim_id, channel_id, comment, parent_id, timestamp, signature, signing_ts)
) )
logger.debug('Inserted Comment into DB, `comment_id`: %s', comment_id) logger.debug('Inserted Comment into DB, `comment_id`: %s', comment_id)
return comment_id return comment_id
def create_comment(conn: sqlite3.Connection, comment: str, claim_id: str, **kwargs) -> typing.Union[dict, None]: def get_comment_or_none(conn: sqlite3.Connection, comment_id: str) -> dict:
channel_id = kwargs.pop('channel_id', '') with conn:
channel_name = kwargs.pop('channel_name', '') curry = conn.execute(
if channel_id or channel_name: """
try: SELECT comment, comment_id, channel_name, channel_id, channel_url, timestamp, signature, signing_ts, parent_id
validate_input( FROM COMMENTS_ON_CLAIMS WHERE comment_id = ?
comment=comment, """,
claim_id=claim_id, (comment_id,)
channel_id=channel_id,
channel_name=channel_name,
)
_insert_channel(conn, channel_name, channel_id)
except AssertionError:
logger.exception('Received invalid input')
raise TypeError('Invalid params given to input validation')
else:
channel_id = None
try:
comment_id = _insert_comment(
conn=conn, comment=comment, claim_id=claim_id, channel_id=channel_id, **kwargs
) )
except sqlite3.IntegrityError as ie: thing = curry.fetchone()
logger.exception(ie) return clean(dict(thing)) if thing else None
return None
curry = conn.execute(
"""
SELECT comment, comment_id, channel_name, channel_id, channel_url, timestamp, signature, parent_id
FROM COMMENTS_ON_CLAIMS WHERE comment_id = ?
""",
(comment_id,)
)
thing = curry.fetchone()
return clean(dict(thing)) if thing else None
def get_comment_ids(conn: sqlite3.Connection, claim_id: str, parent_id: str = None, page=1, page_size=50): def get_comment_ids(conn: sqlite3.Connection, claim_id: str, parent_id: str = None, page=1, page_size=50):
@ -165,18 +129,19 @@ def get_comment_ids(conn: sqlite3.Connection, claim_id: str, parent_id: str = No
For pagination the parameters are: For pagination the parameters are:
get_all XOR (page_size + page) get_all XOR (page_size + page)
""" """
if parent_id is None: with conn:
curs = conn.execute(""" if parent_id is None:
SELECT comment_id FROM COMMENTS_ON_CLAIMS curs = conn.execute("""
WHERE claim_id LIKE ? AND parent_id IS NULL LIMIT ? OFFSET ? SELECT comment_id FROM COMMENTS_ON_CLAIMS
""", (claim_id, page_size, page_size*abs(page - 1),) WHERE claim_id LIKE ? AND parent_id IS NULL LIMIT ? OFFSET ?
) """, (claim_id, page_size, page_size*abs(page - 1),)
else: )
curs = conn.execute(""" else:
SELECT comment_id FROM COMMENTS_ON_CLAIMS curs = conn.execute("""
WHERE claim_id LIKE ? AND parent_id LIKE ? LIMIT ? OFFSET ? SELECT comment_id FROM COMMENTS_ON_CLAIMS
""", (claim_id, parent_id, page_size, page_size * abs(page - 1),) WHERE claim_id LIKE ? AND parent_id LIKE ? LIMIT ? OFFSET ?
) """, (claim_id, parent_id, page_size, page_size * abs(page - 1),)
)
return [tuple(row)[0] for row in curs.fetchall()] return [tuple(row)[0] for row in curs.fetchall()]
@ -184,12 +149,31 @@ def get_comments_by_id(conn, comment_ids: list) -> typing.Union[list, None]:
""" Returns a list containing the comment data associated with each ID within the list""" """ Returns a list containing the comment data associated with each ID within the list"""
# format the input, under the assumption that the # format the input, under the assumption that the
placeholders = ', '.join('?' for _ in comment_ids) placeholders = ', '.join('?' for _ in comment_ids)
return [clean(dict(row)) for row in conn.execute( with conn:
f'SELECT * FROM COMMENTS_ON_CLAIMS WHERE comment_id IN ({placeholders})', return [clean(dict(row)) for row in conn.execute(
tuple(comment_ids) f'SELECT * FROM COMMENTS_ON_CLAIMS WHERE comment_id IN ({placeholders})',
)] tuple(comment_ids)
)]
if __name__ == '__main__': class DatabaseWriter(object):
pass _writer = None
# __generate_database_schema(connection, 'comments_ddl.sql')
def __init__(self, db_file):
if not DatabaseWriter._writer:
self.conn = obtain_connection(db_file)
DatabaseWriter._writer = self
atexit.register(self.cleanup)
logging.info('Database writer has been created at %s', repr(self))
else:
logging.warning('Someone attempted to insantiate DatabaseWriter')
raise TypeError('Database Writer already exists!')
def cleanup(self):
logging.info('Cleaning up database writer')
DatabaseWriter._writer = None
self.conn.close()
@property
def connection(self):
return self.conn

View file

@ -2,17 +2,17 @@
import json import json
import logging import logging
import asyncio
import aiojobs import aiojobs
from asyncio import coroutine import asyncio
from aiohttp import web from aiohttp import web
from aiojobs.aiohttp import atomic from aiojobs.aiohttp import atomic
from asyncio import coroutine
from src.writes import DatabaseWriter from src.database import DatabaseWriter
from src.database import get_claim_comments from src.database import get_claim_comments
from src.database import get_comments_by_id, get_comment_ids from src.database import get_comments_by_id, get_comment_ids
from src.database import obtain_connection from src.database import obtain_connection
from src.database import create_comment from src.writes import create_comment
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -79,6 +79,9 @@ async def process_json(app, body: dict) -> dict:
except TypeError as te: except TypeError as te:
logger.exception('Got TypeError: %s', te) logger.exception('Got TypeError: %s', te)
response['error'] = ERRORS['INVALID_PARAMS'] response['error'] = ERRORS['INVALID_PARAMS']
except ValueError as ve:
logger.exception('Got ValueError: %s', ve)
response['error'] = ERRORS['INVALID_PARAMS']
else: else:
response['error'] = ERRORS['UNKNOWN'] response['error'] = ERRORS['UNKNOWN']
return response return response
@ -87,8 +90,8 @@ async def process_json(app, body: dict) -> dict:
@atomic @atomic
async def api_endpoint(request: web.Request): async def api_endpoint(request: web.Request):
try: try:
body = await request.json()
logger.info('Received POST request from %s', request.remote) logger.info('Received POST request from %s', request.remote)
body = await request.json()
if type(body) is list or type(body) is dict: if type(body) is list or type(body) is dict:
if type(body) is list: if type(body) is list:
return web.json_response( return web.json_response(
@ -104,7 +107,3 @@ async def api_endpoint(request: web.Request):
return web.json_response({ return web.json_response({
'error': {'message': jde.msg, 'code': -1} 'error': {'message': jde.msg, 'code': -1}
}) })

22
src/misc.py Normal file
View file

@ -0,0 +1,22 @@
import re
def validate_channel(channel_id: str, channel_name: str):
assert channel_id and channel_name
assert type(channel_id) is str and type(channel_name) is str
assert re.fullmatch(
'^@(?:(?![\x00-\x08\x0b\x0c\x0e-\x1f\x23-\x26'
'\x2f\x3a\x3d\x3f-\x40\uFFFE-\U0000FFFF]).){1,255}$',
channel_name
)
assert re.fullmatch('[a-z0-9]{40}', channel_id)
def validate_base_comment(comment: str, claim_id: str, **kwargs):
assert 0 < len(comment) <= 2000
assert re.fullmatch('[a-z0-9]{40}', claim_id)
async def validate_signature(*args, **kwargs):
pass

View file

@ -1,30 +1,34 @@
import atexit
import logging import logging
import sqlite3
from src.database import obtain_connection from src.database import get_comment_or_none
from src.database import insert_comment
from src.database import insert_channel
from src.misc import validate_channel
from src.misc import validate_signature
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# DatabaseWriter should be instantiated on startup def create_comment(conn: sqlite3.Connection, comment: str, claim_id: str, channel_id: str = None,
class DatabaseWriter(object): channel_name: str = None, signature: str = None, signing_ts: str = None, parent_id: str = None):
_writer = None if channel_id or channel_name or signature or signing_ts:
validate_signature(signature, signing_ts, comment, channel_name, channel_id)
insert_channel_or_error(conn, channel_name, channel_id)
try:
comment_id = insert_comment(
conn=conn, comment=comment, claim_id=claim_id, channel_id=channel_id,
signature=signature, parent_id=parent_id, signing_ts=signing_ts
)
return get_comment_or_none(conn, comment_id)
except sqlite3.IntegrityError as ie:
logger.exception(ie)
def __init__(self, db_file):
if not DatabaseWriter._writer:
self.conn = obtain_connection(db_file)
DatabaseWriter._writer = self
atexit.register(self.cleanup)
logging.info('Database writer has been created at %s', repr(self))
else:
logging.warning('Someone attempted to insantiate DatabaseWriter')
raise TypeError('Database Writer already exists!')
def cleanup(self): def insert_channel_or_error(conn: sqlite3.Connection, channel_name: str, channel_id: str):
logging.info('Cleaning up database writer') try:
DatabaseWriter._writer = None validate_channel(channel_id, channel_name)
self.conn.close() insert_channel(conn, channel_name, channel_id)
except AssertionError as ae:
@property logger.exception('Invalid channel values given: %s', ae)
def connection(self): raise ValueError('Received invalid values for channel_id or channel_name')
return self.conn

View file

@ -5,11 +5,10 @@ from faker.providers import internet
from faker.providers import lorem from faker.providers import lorem
from faker.providers import misc from faker.providers import misc
from src.database import get_comments_by_id, create_comment, get_comment_ids, \ from src.database import get_comments_by_id, get_comment_ids, \
get_claim_comments get_claim_comments
from schema.db_helpers import setup_database, teardown_database from src.writes import create_comment
from src.settings import config from tests.testcase import DatabaseTestCase
from tests.testcase import DatabaseTestCase, AsyncioTestCase
fake = faker.Faker() fake = faker.Faker()
fake.add_provider(internet) fake.add_provider(internet)
@ -29,9 +28,11 @@ class TestCommentCreation(DatabaseTestCase):
comment='This is a named comment', comment='This is a named comment',
channel_name='@username', channel_name='@username',
channel_id='529357c3422c6046d3fec76be2358004ba22abcd', channel_id='529357c3422c6046d3fec76be2358004ba22abcd',
signature=fake.uuid4(),
signing_ts='aaa'
) )
self.assertIsNotNone(comment) self.assertIsNotNone(comment)
self.assertIsNone(comment['parent_id']) self.assertNotIn('parent_in', comment)
previous_id = comment['comment_id'] previous_id = comment['comment_id']
reply = create_comment( reply = create_comment(
conn=self.conn, conn=self.conn,
@ -39,11 +40,12 @@ class TestCommentCreation(DatabaseTestCase):
comment='This is a named response', comment='This is a named response',
channel_name='@another_username', channel_name='@another_username',
channel_id='529357c3422c6046d3fec76be2358004ba224bcd', channel_id='529357c3422c6046d3fec76be2358004ba224bcd',
parent_id=previous_id parent_id=previous_id,
signature=fake.uuid4(),
signing_ts='aaa'
) )
self.assertIsNotNone(reply) self.assertIsNotNone(reply)
self.assertEqual(reply['parent_id'], comment['comment_id']) self.assertEqual(reply['parent_id'], comment['comment_id'])
self.assertEqual(reply['claim_id'], comment['claim_id'])
def test02AnonymousComments(self): def test02AnonymousComments(self):
comment = create_comment( comment = create_comment(
@ -52,7 +54,6 @@ class TestCommentCreation(DatabaseTestCase):
comment='This is an ANONYMOUS comment' comment='This is an ANONYMOUS comment'
) )
self.assertIsNotNone(comment) self.assertIsNotNone(comment)
self.assertIsNone(comment['parent_id'])
previous_id = comment['comment_id'] previous_id = comment['comment_id']
reply = create_comment( reply = create_comment(
conn=self.conn, conn=self.conn,
@ -62,7 +63,6 @@ class TestCommentCreation(DatabaseTestCase):
) )
self.assertIsNotNone(reply) self.assertIsNotNone(reply)
self.assertEqual(reply['parent_id'], comment['comment_id']) self.assertEqual(reply['parent_id'], comment['comment_id'])
self.assertEqual(reply['claim_id'], comment['claim_id'])
def test03SignedComments(self): def test03SignedComments(self):
comment = create_comment( comment = create_comment(
@ -71,10 +71,11 @@ class TestCommentCreation(DatabaseTestCase):
comment='I like big butts and i cannot lie', comment='I like big butts and i cannot lie',
channel_name='@sirmixalot', channel_name='@sirmixalot',
channel_id='529357c3422c6046d3fec76be2358005ba22abcd', channel_id='529357c3422c6046d3fec76be2358005ba22abcd',
signature='siggy' signature=fake.uuid4(),
signing_ts='asdasd'
) )
self.assertIsNotNone(comment) self.assertIsNotNone(comment)
self.assertIsNone(comment['parent_id']) self.assertIn('signing_ts', comment)
previous_id = comment['comment_id'] previous_id = comment['comment_id']
reply = create_comment( reply = create_comment(
conn=self.conn, conn=self.conn,
@ -83,56 +84,61 @@ class TestCommentCreation(DatabaseTestCase):
channel_name='@LBRY', channel_name='@LBRY',
channel_id='529357c3422c6046d3fec76be2358001ba224bcd', channel_id='529357c3422c6046d3fec76be2358001ba224bcd',
parent_id=previous_id, parent_id=previous_id,
signature='Cursive Font Goes Here' signature=fake.uuid4(),
signing_ts='sfdfdfds'
) )
self.assertIsNotNone(reply) self.assertIsNotNone(reply)
self.assertEqual(reply['parent_id'], comment['comment_id']) self.assertEqual(reply['parent_id'], comment['comment_id'])
self.assertEqual(reply['claim_id'], comment['claim_id']) self.assertIn('signing_ts', reply)
def test04UsernameVariations(self): def test04UsernameVariations(self):
invalid_comment = create_comment( self.assertRaises(
AssertionError,
callable=create_comment,
conn=self.conn, conn=self.conn,
claim_id=self.claimId, claim_id=self.claimId,
channel_name='$#(@#$@#$', channel_name='$#(@#$@#$',
channel_id='529357c3422c6046d3fec76be2358001ba224b23', channel_id='529357c3422c6046d3fec76be2358001ba224b23',
comment='this is an invalid username' comment='this is an invalid username'
) )
self.assertIsNone(invalid_comment)
valid_username = create_comment( valid_username = create_comment(
conn=self.conn, conn=self.conn,
claim_id=self.claimId, claim_id=self.claimId,
channel_name='@' + 'a'*255, channel_name='@' + 'a' * 255,
channel_id='529357c3422c6046d3fec76be2358001ba224b23', channel_id='529357c3422c6046d3fec76be2358001ba224b23',
comment='this is a valid username' comment='this is a valid username'
) )
self.assertIsNotNone(valid_username) self.assertIsNotNone(valid_username)
self.assertRaises(AssertionError,
callable=create_comment,
conn=self.conn,
claim_id=self.claimId,
channel_name='@' + 'a' * 256,
channel_id='529357c3422c6046d3fec76be2358001ba224b23',
comment='this username is too long'
)
lengthy_username = create_comment( self.assertRaises(
conn=self.conn, AssertionError,
claim_id=self.claimId, callable=create_comment,
channel_name='@' + 'a'*256,
channel_id='529357c3422c6046d3fec76be2358001ba224b23',
comment='this username is too long'
)
self.assertIsNone(lengthy_username)
comment = create_comment(
conn=self.conn, conn=self.conn,
claim_id=self.claimId, claim_id=self.claimId,
channel_name='', channel_name='',
channel_id='529357c3422c6046d3fec76be2358001ba224b23', channel_id='529357c3422c6046d3fec76be2358001ba224b23',
comment='this username should not default to ANONYMOUS' comment='this username should not default to ANONYMOUS'
) )
self.assertIsNone(comment) self.assertRaises(
short_username = create_comment( AssertionError,
callable=create_comment,
conn=self.conn, conn=self.conn,
claim_id=self.claimId, claim_id=self.claimId,
channel_name='@', channel_name='@',
channel_id='529357c3422c6046d3fec76be2358001ba224b23', channel_id='529357c3422c6046d3fec76be2358001ba224b23',
comment='this username is too short' comment='this username is too short'
) )
self.assertIsNone(short_username)
def test05InsertRandomComments(self): def test05InsertRandomComments(self):
self.skipTest('This is a bad test')
top_comments, claim_ids = generate_top_comments_random() top_comments, claim_ids = generate_top_comments_random()
total = 0 total = 0
success = 0 success = 0
@ -158,6 +164,7 @@ class TestCommentCreation(DatabaseTestCase):
del claim_ids del claim_ids
def test06GenerateAndListComments(self): def test06GenerateAndListComments(self):
self.skipTest('this is a stupid test')
top_comments, claim_ids = generate_top_comments() top_comments, claim_ids = generate_top_comments()
total, success = 0, 0 total, success = 0, 0
for _, comments in top_comments.items(): for _, comments in top_comments.items():
@ -187,16 +194,10 @@ class ListDatabaseTest(DatabaseTestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
top_coms, self.claim_ids = generate_top_comments(5, 75) top_coms, self.claim_ids = generate_top_comments(5, 75)
self.top_comments = {
commie_id: [create_comment(self.conn, **commie) for commie in commie_list]
for commie_id, commie_list in top_coms.items()
}
self.replies = [
create_comment(self.conn, **reply)
for reply in generate_replies(self.top_comments)
]
def testLists(self): def testLists(self):
self.skipTest('Populating a database each time is not a good way to test listing')
for claim_id in self.claim_ids: for claim_id in self.claim_ids:
with self.subTest(claim_id=claim_id): with self.subTest(claim_id=claim_id):
comments = get_claim_comments(self.conn, claim_id) comments = get_claim_comments(self.conn, claim_id)
@ -217,6 +218,22 @@ class ListDatabaseTest(DatabaseTestCase):
self.assertEqual(len(matching_comments), len(comment_ids)) self.assertEqual(len(matching_comments), len(comment_ids))
def generate_top_comments(ncid=15, ncomm=100, minchar=50, maxchar=500):
claim_ids = [fake.sha1() for _ in range(ncid)]
top_comments = {
cid: [{
'claim_id': cid,
'comment': ''.join(fake.text(max_nb_chars=randint(minchar, maxchar))),
'channel_name': '@' + fake.user_name(),
'channel_id': fake.sha1(),
'signature': fake.uuid4(),
'signing_ts': fake.uuid4()
} for _ in range(ncomm)]
for cid in claim_ids
}
return top_comments, claim_ids
def generate_replies(top_comments): def generate_replies(top_comments):
return [{ return [{
'claim_id': comment['claim_id'], 'claim_id': comment['claim_id'],
@ -224,7 +241,8 @@ def generate_replies(top_comments):
'comment': ' '.join(fake.text(max_nb_chars=randint(50, 500))), 'comment': ' '.join(fake.text(max_nb_chars=randint(50, 500))),
'channel_name': '@' + fake.user_name(), 'channel_name': '@' + fake.user_name(),
'channel_id': fake.sha1(), 'channel_id': fake.sha1(),
'signature': fake.uuid4() 'signature': fake.uuid4(),
'signing_ts': fake.uuid4()
} }
for claim, comments in top_comments.items() for claim, comments in top_comments.items()
for i, comment in enumerate(comments) for i, comment in enumerate(comments)
@ -247,21 +265,6 @@ def generate_replies_random(top_comments):
] ]
def generate_top_comments(ncid=15, ncomm=100, minchar=50, maxchar=500):
claim_ids = [fake.sha1() for _ in range(ncid)]
top_comments = {
cid: [{
'claim_id': cid,
'comment': ''.join(fake.text(max_nb_chars=randint(minchar, maxchar))),
'channel_name': '@' + fake.user_name(),
'channel_id': fake.sha1(),
'signature': fake.uuid4()
} for _ in range(ncomm)]
for cid in claim_ids
}
return top_comments, claim_ids
def generate_top_comments_random(): def generate_top_comments_random():
claim_ids = [fake.sha1() for _ in range(15)] claim_ids = [fake.sha1() for _ in range(15)]
top_comments = { top_comments = {

View file

@ -0,0 +1,21 @@
# For a quick start check out our HTTP Requests collection (Tools|HTTP Client|Open HTTP Requests Collection).
#
# Following HTTP Request Live Templates are available:
# * 'gtrp' and 'gtr' create a GET request with or without query parameters;
# * 'ptr' and 'ptrp' create a POST request with a simple or parameter-like body;
# * 'mptr' and 'fptr' create a POST request to submit a form with a text or file field (multipart/form-data);
POST https://comments.lbry.com/api
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": null,
"method": "get_claim_comments",
"params": {
"claim_id": "9cb713f01bf247a0e03170b5ed00d5161340c486"
}
}
###

View file

@ -0,0 +1,24 @@
# For a quick start check out our HTTP Requests collection (Tools|HTTP Client|Open HTTP Requests Collection).
#
# Following HTTP Request Live Templates are available:
# * 'gtrp' and 'gtr' create a GET request with or without query parameters;
# * 'ptr' and 'ptrp' create a POST request with a simple or parameter-like body;
# * 'mptr' and 'fptr' create a POST request to submit a form with a text or file field (multipart/form-data);
POST https://comments.lbry.com/api
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": null,
"method": "create_comment",
"params": {
"comment": "POP SECRET",
"claim_id": "9cb713f01bf247a0e03170b5ed00d5161340c486",
"channel_name": "@dogeworld",
"channel_id": "9cb713f01bf247a0e03170b5ed00d5161340c486",
"signing_ts": "1234567"
}
}
###

View file

@ -0,0 +1,22 @@
# For a quick start check out our HTTP Requests collection (Tools|HTTP Client|Open HTTP Requests Collection).
#
# Following HTTP Request Live Templates are available:
# * 'gtrp' and 'gtr' create a GET request with or without query parameters;
# * 'ptr' and 'ptrp' create a POST request with a simple or parameter-like body;
# * 'mptr' and 'fptr' create a POST request to submit a form with a text or file field (multipart/form-data);
POST http://localhost:5921/api
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": null,
"method": "create_comment",
"params": {
"claim_id": "9cb713f01bf247a0e03170b5ed00d5161340c486",
"comment": "This is literally, the most anonymous comment I have EVER seen.",
"channel_id": "9cb713f01bf247a0e03170b5ed00d5161340c486",
"channel_name": "@do oge world "
}
}
###

View file

@ -0,0 +1,20 @@
# For a quick start check out our HTTP Requests collection (Tools|HTTP Client|Open HTTP Requests Collection).
#
# Following HTTP Request Live Templates are available:
# * 'gtrp' and 'gtr' create a GET request with or without query parameters;
# * 'ptr' and 'ptrp' create a POST request with a simple or parameter-like body;
# * 'mptr' and 'fptr' create a POST request to submit a form with a text or file field (multipart/form-data);
POST http://localhost:5921/api
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": null,
"method": "get_claim_comments",
"params": {
"claim_id": "9cb713f01bf247a0e03170b5ed00d5161340c486",
"page": 1
}
}
###

View file

@ -1,11 +1,12 @@
import pathlib
import unittest import unittest
from asyncio.runners import _cancel_all_tasks # type: ignore from asyncio.runners import _cancel_all_tasks # type: ignore
from unittest.case import _Outcome from unittest.case import _Outcome
import asyncio import asyncio
from src.database import obtain_connection
from schema.db_helpers import setup_database, teardown_database from schema.db_helpers import setup_database, teardown_database
from src.database import obtain_connection
from src.settings import config from src.settings import config
@ -120,6 +121,8 @@ class AsyncioTestCase(unittest.TestCase):
class DatabaseTestCase(unittest.TestCase): class DatabaseTestCase(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
if pathlib.Path(config['PATH']['TEST']).exists():
teardown_database(config['PATH']['TEST'])
setup_database(config['PATH']['TEST']) setup_database(config['PATH']['TEST'])
self.conn = obtain_connection(config['PATH']['TEST']) self.conn = obtain_connection(config['PATH']['TEST'])