Compare commits

..

No commits in common. "master" and "orm-rewrite" have entirely different histories.

16 changed files with 267 additions and 95 deletions

View file

@ -30,5 +30,6 @@ jobs:
name: "Unit Tests" name: "Unit Tests"
install: install:
- pip install -e . - pip install -e .
- mkdir database
script: script:
- python -m unittest - python -m unittest

View file

@ -3,36 +3,34 @@
[![Build Status](https://travis-ci.com/lbryio/comment-server.svg?branch=master)](https://travis-ci.com/lbryio/comment-server) [![Build Status](https://travis-ci.com/lbryio/comment-server.svg?branch=master)](https://travis-ci.com/lbryio/comment-server)
[![Maintainability](https://api.codeclimate.com/v1/badges/22f420b8b5f2373fd885/maintainability)](https://codeclimate.com/github/lbryio/comment-server/maintainability) [![Maintainability](https://api.codeclimate.com/v1/badges/22f420b8b5f2373fd885/maintainability)](https://codeclimate.com/github/lbryio/comment-server/maintainability)
This is the code for the LBRY Comment Server.
Fork it, run it, set it on fire. Up to you.
## Before Installing ## Before Installing
Install the [`lbry-sdk`](https://github.com/lbryio/lbry-sdk) Comment Deletion requires having the [`lbry-sdk`](https://github.com/lbryio/lbry-sdk)
in order to validate & properly delete comments. in order to validate & properly delete comments.
## Installation ## Installation
#### Installing the server: #### Installing the server:
```bash ```bash
$ git clone https://github.com/lbryio/comment-server $ git clone https://github.com/osilkin98/comment-server
$ cd comment-server $ cd comment-server
# create a virtual environment # create a virtual environment
$ virtualenv --python=python3.8 venv $ virtualenv --python=python3 venv
# Enter the virtual environment # Enter the virtual environment
$ source venv/bin/activate $ source venv/bin/activate
# Install required dependencies # install the Server as a Executable Target
(venv) $ pip install -e . (venv) $ python setup.py develop
# Run the server
(venv) $ python src/main.py \
--port=5921 \ # use a different port besides the default
--config=conf.yml \ # provide a custom config file
& \ # detach and run the service in the background
``` ```
### Installing the systemd Service Monitor ### Installing the systemd Service Monitor
@ -72,11 +70,16 @@ To Test the database, simply run:
There are basic tests to run against the server, though they require There are basic tests to run against the server, though they require
that there is a server instance running, though the database that there is a server instance running, though the database
chosen may have to be edited in `config/conf.yml`. chosen may have to be edited in `config/conf.json`.
Additionally there are HTTP requests that can be send with whatever Additionally there are HTTP requests that can be send with whatever
software you choose to test the integrity of the comment server. software you choose to test the integrity of the comment server.
## Schema
![schema](schema.png)
## Contributing ## Contributing
Contributions are welcome, verbosity is encouraged. Please be considerate Contributions are welcome, verbosity is encouraged. Please be considerate

View file

@ -12,9 +12,8 @@ testing:
# actual database should be running MySQL # actual database should be running MySQL
production: production:
charset: utf8mb4
database: mysql database: mysql
name: social name: lbry
user: lbry user: lbry
password: lbry password: lbry
host: localhost host: localhost

View file

@ -6,12 +6,11 @@ services:
mysql: mysql:
image: mysql/mysql-server:5.7.27 image: mysql/mysql-server:5.7.27
restart: "no" restart: "no"
command: --character_set_server=utf8mb4 --max_allowed_packet=1073741824
ports: ports:
- "3306:3306" - "3306:3306"
environment: environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=true - MYSQL_ALLOW_EMPTY_PASSWORD=true
- MYSQL_DATABASE=social - MYSQL_DATABASE=lbry
- MYSQL_USER=lbry - MYSQL_USER=lbry
- MYSQL_PASSWORD=lbry - MYSQL_PASSWORD=lbry
- MYSQL_LOG_CONSOLE=true - MYSQL_LOG_CONSOLE=true

84
scripts/stress_test.py Normal file
View file

@ -0,0 +1,84 @@
import sqlite3
import time
import faker
from faker.providers import misc
fake = faker.Faker()
fake.add_provider(misc)
if __name__ == '__main__':
song_time = """One, two, three!
My baby don't mess around
'Cause she loves me so
This I know fo sho!
But does she really wanna
But can't stand to see me walk out tha door
Don't try to fight the feeling
Because the thought alone is killin' me right now
Thank God for Mom and Dad
For sticking to together
Like we don't know how
Hey ya! Hey ya!
Hey ya! Hey ya!
Hey ya! Hey ya!
Hey ya! Hey ya!
You think you've got it
Oh, you think you've got it
But got it just don't get it when there's nothin' at all
We get together
Oh, we get together
But separate's always better when there's feelings involved
Know what they say -its
Nothing lasts forever!
Then what makes it, then what makes it
Then what makes it, then what makes it
Then what makes love the exception?
So why, oh, why, oh
Why, oh, why, oh, why, oh
Are we still in denial when we know we're not happy here
Hey ya! (y'all don't want to here me, ya just want to dance) Hey ya!
Don't want to meet your daddy (oh ohh), just want you in my caddy (oh ohh)
Hey ya! (oh, oh!) Hey ya! (oh, oh!)
Don't want to meet your momma, just want to make you cum-a (oh, oh!)
I'm (oh, oh) I'm (oh, oh) I'm just being honest! (oh, oh)
I'm just being honest!
Hey! alright now! alright now, fellas!
Yea?
Now, what cooler than being cool?
Ice cold!
I can't hear ya! I say what's, what's cooler than being cool?
Ice cold!
Alright alright alright alright alright alright alright alright alright alright alright alright alright alright alright alright!
Okay, now ladies!
Yea?
Now we gonna break this thang down for just a few seconds
Now don't have me break this thang down for nothin'
I want to see you on your badest behavior!
Lend me some sugar, I am your neighbor!
Ah! Here we go now,
Shake it, shake it, shake it, shake it, shake it
Shake it, shake it, shake it, shake it
Shake it like a Polaroid picture! Hey ya!
Shake it, shake it, shake it, shake it, shake it
Shake it, shake it, shake it, suga!
Shake it like a Polaroid picture!
Now all the Beyonce's, and Lucy Lu's, and baby dolls
Get on tha floor get on tha floor!
Shake it like a Polaroid picture!
Oh, you! oh, you!
Hey ya!(oh, oh) Hey ya!(oh, oh)
Hey ya!(oh, oh) Hey ya!(oh, oh)
Hey ya!(oh, oh) Hey ya!(oh, oh)"""
song = song_time.split('\n')
claim_id = '2aa106927b733e2602ffb565efaccc78c2ed89df'
run_len = [(fake.sha256(), song_time, claim_id, str(int(time.time()))) for k in range(5000)]
conn = sqlite3.connect('database/default_test.db')
with conn:
curs = conn.executemany("""
INSERT INTO COMMENT(CommentId, Body, LbryClaimId, Timestamp) VALUES (?, ?, ?, ?)
""", run_len)
print(f'rows changed: {curs.rowcount}')

View file

@ -2,12 +2,11 @@ import binascii
import logging import logging
import hashlib import hashlib
import json import json
# todo: remove sqlite3 as a dependency
import sqlite3 import sqlite3
import asyncio import asyncio
import aiohttp import aiohttp
from src.server.validation import is_signature_valid, get_encoded_signature from server.validation import is_signature_valid, get_encoded_signature
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,50 +1,76 @@
USE `social`; PRAGMA FOREIGN_KEYS = ON;
ALTER DATABASE `social`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `CHANNEL`; -- Although I know this file is unnecessary, I like keeping it around.
CREATE TABLE `CHANNEL` (
`claimid` VARCHAR(40) NOT NULL,
`name` CHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
CONSTRAINT `channel_pk` PRIMARY KEY (`claimid`)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `COMMENT`; -- I'm not gonna remove it.
CREATE TABLE `COMMENT` (
-- should be changed to CHAR(64)
`commentid` CHAR(64) NOT NULL,
-- should be changed to CHAR(40)
`lbryclaimid` CHAR(40) NOT NULL,
-- can be null, so idk if this should be char(40)
`channelid` CHAR(40) DEFAULT NULL,
`body` TEXT
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
NOT NULL,
`parentid` CHAR(64) DEFAULT NULL,
`signature` CHAR(128) DEFAULT NULL,
-- 22 chars long is prolly enough
`signingts` VARCHAR(22) DEFAULT NULL,
`timestamp` INTEGER NOT NULL, -- tables
-- there's no way that the timestamp will ever reach 22 characters CREATE TABLE IF NOT EXISTS COMMENT
`ishidden` BOOLEAN DEFAULT FALSE, (
CONSTRAINT `COMMENT_PRIMARY_KEY` PRIMARY KEY (`commentid`) CommentId TEXT NOT NULL,
-- setting null implies comment is top level LbryClaimId TEXT NOT NULL,
) ChannelId TEXT DEFAULT NULL,
CHARACTER SET utf8mb4 Body TEXT NOT NULL,
COLLATE utf8mb4_unicode_ci; ParentId TEXT DEFAULT NULL,
Signature TEXT DEFAULT NULL,
Timestamp INTEGER NOT NULL,
SigningTs TEXT DEFAULT NULL,
IsHidden BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT COMMENT_PRIMARY_KEY PRIMARY KEY (CommentId) ON CONFLICT IGNORE,
CONSTRAINT COMMENT_SIGNATURE_SK UNIQUE (Signature) ON CONFLICT ABORT,
CONSTRAINT COMMENT_CHANNEL_FK FOREIGN KEY (ChannelId) REFERENCES CHANNEL (ClaimId)
ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT COMMENT_PARENT_FK FOREIGN KEY (ParentId) REFERENCES COMMENT (CommentId)
ON UPDATE CASCADE ON DELETE NO ACTION -- setting null implies comment is top level
);
-- ALTER TABLE COMMENT ADD COLUMN IsHidden BOOLEAN DEFAULT (FALSE);
-- ALTER TABLE COMMENT ADD COLUMN SigningTs TEXT DEFAULT NULL;
-- DROP TABLE IF EXISTS CHANNEL;
CREATE TABLE IF NOT EXISTS CHANNEL
(
ClaimId TEXT NOT NULL,
Name TEXT NOT NULL,
CONSTRAINT CHANNEL_PK PRIMARY KEY (ClaimId)
ON CONFLICT IGNORE
);
ALTER TABLE COMMENT -- indexes
ADD CONSTRAINT `comment_channel_fk` FOREIGN KEY (`channelid`) REFERENCES `CHANNEL` (`claimid`) -- DROP INDEX IF EXISTS COMMENT_CLAIM_INDEX;
ON DELETE CASCADE ON UPDATE CASCADE, -- CREATE INDEX IF NOT EXISTS CLAIM_COMMENT_INDEX ON COMMENT (LbryClaimId, CommentId);
ADD CONSTRAINT `comment_parent_fk` FOREIGN KEY (`parentid`) REFERENCES `COMMENT` (`commentid`)
ON UPDATE CASCADE ON DELETE CASCADE
;
CREATE INDEX `claim_comment_index` ON `COMMENT` (`lbryclaimid`, `commentid`); -- CREATE INDEX IF NOT EXISTS CHANNEL_COMMENT_INDEX ON COMMENT (ChannelId, CommentId);
CREATE INDEX `channel_comment_index` ON `COMMENT` (`channelid`, `commentid`);
-- VIEWS
CREATE VIEW IF NOT EXISTS COMMENTS_ON_CLAIMS AS
SELECT C.CommentId AS comment_id,
C.Body AS comment,
C.LbryClaimId AS claim_id,
C.Timestamp AS timestamp,
CHAN.Name AS channel_name,
CHAN.ClaimId AS channel_id,
('lbry://' || CHAN.Name || '#' || CHAN.ClaimId) AS channel_url,
C.Signature AS signature,
C.SigningTs AS signing_ts,
C.ParentId AS parent_id,
C.IsHidden AS is_hidden
FROM COMMENT AS C
LEFT OUTER JOIN CHANNEL CHAN ON C.ChannelId = CHAN.ClaimId
ORDER BY C.Timestamp DESC;
DROP VIEW IF EXISTS COMMENT_REPLIES;
CREATE VIEW IF NOT EXISTS COMMENT_REPLIES (Author, CommentBody, ParentAuthor, ParentCommentBody) AS
SELECT AUTHOR.Name, OG.Body, PCHAN.Name, PARENT.Body
FROM COMMENT AS OG
JOIN COMMENT AS PARENT
ON OG.ParentId = PARENT.CommentId
JOIN CHANNEL AS PCHAN ON PARENT.ChannelId = PCHAN.ClaimId
JOIN CHANNEL AS AUTHOR ON OG.ChannelId = AUTHOR.ClaimId
ORDER BY OG.Timestamp;
-- this is the default channel for anyone who wants to publish anonymously
-- INSERT INTO CHANNEL
-- VALUES ('9cb713f01bf247a0e03170b5ed00d5161340c486', '@Anonymous');

View file

@ -13,25 +13,25 @@ from src.misc import clean
class Channel(Model): class Channel(Model):
claim_id = FixedCharField(column_name='claimid', primary_key=True, max_length=40) claim_id = CharField(column_name='ClaimId', primary_key=True, max_length=40)
name = CharField(column_name='name', max_length=255) name = CharField(column_name='Name', max_length=256)
class Meta: class Meta:
table_name = 'CHANNEL' table_name = 'CHANNEL'
class Comment(Model): class Comment(Model):
comment = TextField(column_name='body') comment = CharField(column_name='Body', max_length=2000)
channel = ForeignKeyField( channel = ForeignKeyField(
backref='comments', backref='comments',
column_name='channelid', column_name='ChannelId',
field='claim_id', field='claim_id',
model=Channel, model=Channel,
null=True null=True
) )
comment_id = FixedCharField(column_name='commentid', primary_key=True, max_length=64) comment_id = CharField(column_name='CommentId', primary_key=True, max_length=64)
is_hidden = BooleanField(column_name='ishidden', constraints=[SQL("DEFAULT 0")]) is_hidden = BooleanField(column_name='IsHidden', constraints=[SQL("DEFAULT 0")])
claim_id = FixedCharField(max_length=40, column_name='lbryclaimid') claim_id = CharField(max_length=40, column_name='LbryClaimId')
parent = ForeignKeyField( parent = ForeignKeyField(
column_name='ParentId', column_name='ParentId',
field='comment_id', field='comment_id',
@ -39,9 +39,9 @@ class Comment(Model):
null=True, null=True,
backref='replies' backref='replies'
) )
signature = FixedCharField(max_length=128, column_name='signature', null=True, unique=True) signature = CharField(max_length=128, column_name='Signature', null=True, unique=True)
signing_ts = TextField(column_name='signingts', null=True) signing_ts = TextField(column_name='SigningTs', null=True)
timestamp = IntegerField(column_name='timestamp') timestamp = IntegerField(column_name='Timestamp')
class Meta: class Meta:
table_name = 'COMMENT' table_name = 'COMMENT'

76
src/database/schema.py Normal file
View file

@ -0,0 +1,76 @@
PRAGMAS = """
PRAGMA FOREIGN_KEYS = ON;
"""
CREATE_COMMENT_TABLE = """
CREATE TABLE IF NOT EXISTS COMMENT (
CommentId TEXT NOT NULL,
LbryClaimId TEXT NOT NULL,
ChannelId TEXT DEFAULT NULL,
Body TEXT NOT NULL,
ParentId TEXT DEFAULT NULL,
Signature TEXT DEFAULT NULL,
Timestamp INTEGER NOT NULL,
SigningTs TEXT DEFAULT NULL,
IsHidden BOOLEAN NOT NULL DEFAULT 0,
CONSTRAINT COMMENT_PRIMARY_KEY PRIMARY KEY (CommentId) ON CONFLICT IGNORE,
CONSTRAINT COMMENT_SIGNATURE_SK UNIQUE (Signature) ON CONFLICT ABORT,
CONSTRAINT COMMENT_CHANNEL_FK FOREIGN KEY (ChannelId) REFERENCES CHANNEL (ClaimId)
ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT COMMENT_PARENT_FK FOREIGN KEY (ParentId) REFERENCES COMMENT (CommentId)
ON UPDATE CASCADE ON DELETE NO ACTION -- setting null implies comment is top level
);
"""
CREATE_COMMENT_INDEXES = """
CREATE INDEX IF NOT EXISTS CLAIM_COMMENT_INDEX ON COMMENT (LbryClaimId, CommentId);
CREATE INDEX IF NOT EXISTS CHANNEL_COMMENT_INDEX ON COMMENT (ChannelId, CommentId);
"""
CREATE_CHANNEL_TABLE = """
CREATE TABLE IF NOT EXISTS CHANNEL (
ClaimId TEXT NOT NULL,
Name TEXT NOT NULL,
CONSTRAINT CHANNEL_PK PRIMARY KEY (ClaimId)
ON CONFLICT IGNORE
);
"""
CREATE_COMMENTS_ON_CLAIMS_VIEW = """
CREATE VIEW IF NOT EXISTS COMMENTS_ON_CLAIMS AS SELECT
C.CommentId AS comment_id,
C.Body AS comment,
C.LbryClaimId AS claim_id,
C.Timestamp AS timestamp,
CHAN.Name AS channel_name,
CHAN.ClaimId AS channel_id,
('lbry://' || CHAN.Name || '#' || CHAN.ClaimId) AS channel_url,
C.Signature AS signature,
C.SigningTs AS signing_ts,
C.ParentId AS parent_id,
C.IsHidden AS is_hidden
FROM COMMENT AS C
LEFT OUTER JOIN CHANNEL CHAN ON C.ChannelId = CHAN.ClaimId
ORDER BY C.Timestamp DESC;
"""
# not being used right now but should be kept around when Tom finally asks for replies
CREATE_COMMENT_REPLIES_VIEW = """
CREATE VIEW IF NOT EXISTS COMMENT_REPLIES (Author, CommentBody, ParentAuthor, ParentCommentBody) AS
SELECT AUTHOR.Name, OG.Body, PCHAN.Name, PARENT.Body
FROM COMMENT AS OG
JOIN COMMENT AS PARENT
ON OG.ParentId = PARENT.CommentId
JOIN CHANNEL AS PCHAN ON PARENT.ChannelId = PCHAN.ClaimId
JOIN CHANNEL AS AUTHOR ON OG.ChannelId = AUTHOR.ClaimId
ORDER BY OG.Timestamp;
"""
CREATE_TABLES_QUERY = (
PRAGMAS +
CREATE_COMMENT_TABLE +
CREATE_COMMENT_INDEXES +
CREATE_CHANNEL_TABLE +
CREATE_COMMENTS_ON_CLAIMS_VIEW +
CREATE_COMMENT_REPLIES_VIEW
)

View file

@ -1,4 +1,3 @@
# TODO: scrap notification routines from these files & supply them in handles
import logging import logging
import sqlite3 import sqlite3
from asyncio import coroutine from asyncio import coroutine

View file

@ -1,4 +1,5 @@
import argparse import argparse
import json
import yaml import yaml
import logging import logging
import logging.config import logging.config

View file

@ -16,7 +16,7 @@ async def get_claim_from_id(app, claim_id, **kwargs):
def clean_input_params(kwargs: dict): def clean_input_params(kwargs: dict):
for k, v in kwargs.items(): for k, v in kwargs.items():
if type(v) is str and k != 'comment': if type(v) is str and k is not 'comment':
kwargs[k] = v.strip() kwargs[k] = v.strip()
if k in ID_LIST: if k in ID_LIST:
kwargs[k] = v.lower() kwargs[k] = v.lower()

View file

@ -28,7 +28,6 @@ def setup_database(app):
host=config[mode]['host'], host=config[mode]['host'],
password=config[mode]['password'], password=config[mode]['password'],
port=config[mode]['port'], port=config[mode]['port'],
charset=config[mode]['charset'],
) )
elif config[mode]['database'] == 'sqlite': elif config[mode]['database'] == 'sqlite':
app['db'] = SqliteDatabase( app['db'] = SqliteDatabase(

View file

@ -1,5 +1,3 @@
import json
import logging import logging
import aiohttp import aiohttp
@ -34,11 +32,8 @@ def make_error(error, exc=None) -> dict:
async def report_error(app, exc, body: dict): async def report_error(app, exc, body: dict):
try: try:
if 'slack_webhook' in app['config']: if 'slack_webhook' in app['config']:
body_dump = json.dumps(body, indent=4)
exec_name = type(exc).__name__
exec_body = str(exc)
message = { message = {
"text": f"Got `{exec_name}`: `\n{exec_body}`\n```{body_dump}```" "text": f"Got `{type(exc).__name__}`: `\n{str(exc)}`\n```{body}```"
} }
async with aiohttp.ClientSession() as sesh: async with aiohttp.ClientSession() as sesh:
async with sesh.post(app['config']['slack_webhook'], json=message) as resp: async with sesh.post(app['config']['slack_webhook'], json=message) as resp:

View file

@ -37,10 +37,6 @@ def create_notification_batch(action: str, comments: List[dict]) -> List[dict]:
} }
if comment.get('channel_id'): if comment.get('channel_id'):
event['channel_id'] = comment['channel_id'] event['channel_id'] = comment['channel_id']
if comment.get('parent_id'):
event['parent_id'] = comment['parent_id']
if comment.get('comment'):
event['comment'] = comment['comment']
events.append(event) events.append(event)
return events return events

View file

@ -7,7 +7,6 @@ from aiohttp import web
from aiojobs.aiohttp import atomic from aiojobs.aiohttp import atomic
from peewee import DoesNotExist from peewee import DoesNotExist
from src.server.external import send_notification
from src.server.validation import validate_signature_from_claim from src.server.validation import validate_signature_from_claim
from src.misc import clean_input_params, get_claim_from_id from src.misc import clean_input_params, get_claim_from_id
from src.server.errors import make_error, report_error from src.server.errors import make_error, report_error
@ -117,7 +116,7 @@ async def handle_abandon_comment(
else: else:
if not validate_signature_from_claim(channel, signature, signing_ts, comment_id): if not validate_signature_from_claim(channel, signature, signing_ts, comment_id):
raise ValueError('Abandon signature could not be validated') raise ValueError('Abandon signature could not be validated')
await app['webhooks'].spawn(send_notification(app, 'DELETE', comment))
with app['db'].atomic(): with app['db'].atomic():
return { return {
'abandoned': delete_comment(comment_id) 'abandoned': delete_comment(comment_id)
@ -185,17 +184,15 @@ async def handle_edit_comment(app, comment: str = None, comment_id: str = None,
with app['db'].atomic(): with app['db'].atomic():
if not edit_comment(comment_id, comment, signature, signing_ts): if not edit_comment(comment_id, comment, signature, signing_ts):
raise ValueError('Comment could not be edited') raise ValueError('Comment could not be edited')
updated_comment = get_comment(comment_id) return get_comment(comment_id)
await app['webhooks'].spawn(send_notification(app, 'UPDATE', updated_comment))
return updated_comment
# TODO: retrieve stake amounts for each channel & store in db # TODO: retrieve stake amounts for each channel & store in db
async def handle_create_comment(app, comment: str = None, claim_id: str = None, def handle_create_comment(app, comment: str = None, claim_id: str = None,
parent_id: str = None, channel_id: str = None, channel_name: str = None, parent_id: str = None, channel_id: str = None, channel_name: str = None,
signature: str = None, signing_ts: str = None) -> dict: signature: str = None, signing_ts: str = None) -> dict:
with app['db'].atomic(): with app['db'].atomic():
comment = create_comment( return create_comment(
comment=comment, comment=comment,
claim_id=claim_id, claim_id=claim_id,
parent_id=parent_id, parent_id=parent_id,
@ -204,8 +201,6 @@ async def handle_create_comment(app, comment: str = None, claim_id: str = None,
signature=signature, signature=signature,
signing_ts=signing_ts signing_ts=signing_ts
) )
await app['webhooks'].spawn(send_notification(app, 'CREATE', comment))
return comment
METHODS = { METHODS = {