Compare commits
39 Commits
Author | SHA1 | Date |
---|---|---|
Martin Zimmermann | 01d86881ca | 10 years ago |
Martin Zimmermann | e2e69c4124 | 10 years ago |
Martin Zimmermann | 3809f49f98 | 10 years ago |
Martin Zimmermann | 49f0031157 | 10 years ago |
Martin Zimmermann | 2a0898c928 | 10 years ago |
Martin Zimmermann | cffe8cea08 | 10 years ago |
Martin Zimmermann | c9333adc5c | 10 years ago |
Martin Zimmermann | 08cb3cd324 | 10 years ago |
Martin Zimmermann | e706fabb26 | 10 years ago |
Martin Zimmermann | a0a2662cc9 | 10 years ago |
Martin Zimmermann | 8d2b4b4584 | 10 years ago |
Martin Zimmermann | c9ff66e172 | 10 years ago |
Martin Zimmermann | 95dba92d46 | 10 years ago |
Martin Zimmermann | db9bfddc13 | 10 years ago |
Martin Zimmermann | 6245a8dc17 | 10 years ago |
Martin Zimmermann | bd1cb498d1 | 10 years ago |
Martin Zimmermann | fc2cc0c65f | 10 years ago |
Martin Zimmermann | ff272f60ce | 10 years ago |
Martin Zimmermann | 0365b7057a | 10 years ago |
Nicolas Le Manchet | 7174c6a686 | 10 years ago |
Martin Zimmermann | b2f925f99b | 10 years ago |
Nicolas Le Manchet | 43d8ae702d | 10 years ago |
Martin Zimmermann | 9aa1e9544d | 10 years ago |
Martin Zimmermann | 131951c976 | 10 years ago |
Martin Zimmermann | 7a3251ddfc | 10 years ago |
Martin Zimmermann | 7886c20aef | 10 years ago |
Martin Zimmermann | d472262fee | 10 years ago |
Martin Zimmermann | 80cbf2676f | 10 years ago |
Martin Zimmermann | 4f152d03ac | 10 years ago |
Martin Zimmermann | 10960ecf1e | 10 years ago |
Martin Zimmermann | 1e542e612a | 10 years ago |
Martin Zimmermann | a551271743 | 10 years ago |
Martin Zimmermann | 88689c789a | 10 years ago |
Martin Zimmermann | bbd9e1b523 | 10 years ago |
Martin Zimmermann | b2bc582f92 | 10 years ago |
Martin Zimmermann | 5f71b735e5 | 10 years ago |
Martin Zimmermann | bffcc3af6f | 10 years ago |
Martin Zimmermann | 1a4e760fe0 | 10 years ago |
Martin Zimmermann | 65caa7ad99 | 10 years ago |
@ -1,9 +1,12 @@
|
||||
include man/man1/isso.1
|
||||
include man/man5/isso.conf.5
|
||||
|
||||
include isso/defaults.ini
|
||||
include share/isso.conf
|
||||
|
||||
include isso/js/embed.min.js
|
||||
include isso/js/embed.dev.js
|
||||
include isso/js/count.min.js
|
||||
include isso/js/count.dev.js
|
||||
|
||||
include isso/demo/index.html
|
||||
|
@ -0,0 +1,105 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import abc
|
||||
import json
|
||||
|
||||
from isso.utils import types
|
||||
from isso.compat import string_types
|
||||
|
||||
|
||||
def pickle(value):
|
||||
return json.dumps(value).encode("utf-8")
|
||||
|
||||
|
||||
def unpickle(value):
|
||||
return json.loads(value.decode("utf-8"))
|
||||
|
||||
|
||||
class Base(object):
|
||||
"""Base class for all cache objects.
|
||||
|
||||
Arbitrary values are set by namespace and key. Namespace and key must be
|
||||
strings, the underlying cache implementation may use :func:`pickle` and
|
||||
:func:`unpickle:` to safely un-/serialize Python primitives.
|
||||
|
||||
:param threshold: maximum size of the cache
|
||||
:param timeout: key expiration
|
||||
"""
|
||||
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
# enable serialization of Python primitives
|
||||
serialize = False
|
||||
|
||||
def __init__(self, threshold, timeout):
|
||||
self.threshold = threshold
|
||||
self.timeout = timeout
|
||||
|
||||
def get(self, ns, key, default=None):
|
||||
types.require(ns, string_types)
|
||||
types.require(key, string_types)
|
||||
|
||||
try:
|
||||
value = self._get(ns.encode("utf-8"), key.encode("utf-8"))
|
||||
except KeyError:
|
||||
return default
|
||||
else:
|
||||
if self.serialize:
|
||||
value = unpickle(value)
|
||||
return value
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get(self, ns, key):
|
||||
return
|
||||
|
||||
def set(self, ns, key, value):
|
||||
types.require(ns, string_types)
|
||||
types.require(key, string_types)
|
||||
|
||||
if self.serialize:
|
||||
value = pickle(value)
|
||||
|
||||
return self._set(ns.encode("utf-8"), key.encode("utf-8"), value)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _set(self, ns, key, value):
|
||||
return
|
||||
|
||||
def delete(self, ns, key):
|
||||
types.require(ns, string_types)
|
||||
types.require(key, string_types)
|
||||
|
||||
return self._delete(ns.encode("utf-8"), key.encode("utf-8"))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _delete(self, ns, key):
|
||||
return
|
||||
|
||||
|
||||
class Cache(Base):
|
||||
"""Implements a simple in-memory cache; once the threshold is reached, all
|
||||
cached elements are discarded (the timeout parameter is ignored).
|
||||
"""
|
||||
|
||||
def __init__(self, threshold=512, timeout=-1):
|
||||
super(Cache, self).__init__(threshold, timeout)
|
||||
self.cache = {}
|
||||
|
||||
def _get(self, ns, key):
|
||||
return self.cache[ns + b'-' + key]
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
if len(self.cache) > self.threshold - 1:
|
||||
self.cache.clear()
|
||||
self.cache[ns + b'-' + key] = value
|
||||
|
||||
def _delete(self, ns, key):
|
||||
self.cache.pop(ns + b'-' + key, None)
|
||||
|
||||
|
||||
from .sa import SACache
|
||||
from .uwsgi import uWSGICache
|
||||
|
||||
__all__ = ["Cache", "SACache", "uWSGICache"]
|
@ -0,0 +1,83 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import Table, Column, MetaData, create_engine
|
||||
from sqlalchemy import Float, String, LargeBinary
|
||||
from sqlalchemy.sql import select, func
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
def get(con, cache, key):
|
||||
rv = con.execute(
|
||||
select([cache.c.value]).where(
|
||||
cache.c.key == key)).fetchone()
|
||||
|
||||
if rv is None:
|
||||
raise KeyError
|
||||
|
||||
return rv[0]
|
||||
|
||||
|
||||
def set(con, cache, key, value, threshold):
|
||||
cnt = con.execute(
|
||||
select([func.count(cache)])).fetchone()[0]
|
||||
|
||||
if cnt + 1 > threshold:
|
||||
con.execute(
|
||||
cache.delete().where(
|
||||
cache.c.key.in_(select(
|
||||
[cache.c.key])
|
||||
.order_by(cache.c.time)
|
||||
.limit(1))))
|
||||
|
||||
try:
|
||||
get(con, cache, key)
|
||||
except KeyError:
|
||||
insert = True
|
||||
else:
|
||||
insert = False
|
||||
|
||||
if insert:
|
||||
stmt = cache.insert().values(key=key, value=value, time=time.time())
|
||||
else:
|
||||
stmt = cache.update().values(value=value, time=time.time()) \
|
||||
.where(cache.c.key == key)
|
||||
|
||||
con.execute(stmt)
|
||||
|
||||
|
||||
def delete(con, cache, key):
|
||||
con.execute(cache.delete(cache.c.key == key))
|
||||
|
||||
|
||||
class SACache(Base):
|
||||
"""Implements cache using SQLAlchemy Core.
|
||||
"""
|
||||
|
||||
serialize = True
|
||||
|
||||
def __init__(self, db, threshold=1024, timeout=-1):
|
||||
super(SACache, self).__init__(threshold, timeout)
|
||||
self.metadata = MetaData()
|
||||
self.engine = create_engine(db)
|
||||
|
||||
self.cache = Table("cache", self.metadata,
|
||||
Column("key", String(255), primary_key=True),
|
||||
Column("value", LargeBinary(65535)),
|
||||
Column("time", Float))
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.engine.execute(self.cache.delete())
|
||||
|
||||
def _get(self, ns, key):
|
||||
return get(self.engine.connect(), self.cache, ns + b'-' + key)
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
set(self.engine.connect(), self.cache, ns + b'-' + key, value, self.threshold)
|
||||
|
||||
def _delete(self, ns, key):
|
||||
delete(self.engine.connect(), self.cache, ns + b'-' + key)
|
@ -0,0 +1,34 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
try:
|
||||
import uwsgi
|
||||
except ImportError:
|
||||
uwsgi = None
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
class uWSGICache(Base):
|
||||
"""Utilize uWSGI caching framework, in-memory and SMP-safe.
|
||||
"""
|
||||
|
||||
serialize = True
|
||||
|
||||
def __init__(self, threshold=-1, timeout=3600):
|
||||
if uwsgi is None:
|
||||
raise RuntimeError("uWSGI not available")
|
||||
|
||||
super(uWSGICache, self).__init__(threshold, timeout)
|
||||
|
||||
def _get(self, ns, key):
|
||||
if not uwsgi.cache_exists(key, ns):
|
||||
raise KeyError
|
||||
return uwsgi.cache_get(key, ns)
|
||||
|
||||
def _delete(self, ns, key):
|
||||
uwsgi.cache_del(key, ns)
|
||||
|
||||
def _set(self, ns, key, value):
|
||||
uwsgi.cache_set(key, value, self.timeout, ns)
|
@ -0,0 +1,291 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy.sql import func, select, not_
|
||||
|
||||
from isso.spam import Guard
|
||||
from isso.utils import Bloomfilter
|
||||
from isso.models import Comment
|
||||
|
||||
from isso.compat import string_types, buffer
|
||||
|
||||
|
||||
class Invalid(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Denied(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Validator(object):
|
||||
|
||||
# from Django appearently, looks good to me *duck*
|
||||
__url_re = re.compile(
|
||||
r'^'
|
||||
r'(https?://)?'
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
|
||||
r'localhost|' # localhost...
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||
r'(?::\d+)?' # optional port
|
||||
r'(?:/?|[/?]\S+)'
|
||||
r'$', re.IGNORECASE)
|
||||
|
||||
@classmethod
|
||||
def isurl(cls, text):
|
||||
return Validator.__url_re.match(text) is not None
|
||||
|
||||
@classmethod
|
||||
def verify(cls, comment):
|
||||
|
||||
if not isinstance(comment["parent"], (int, type(None))):
|
||||
return False, "parent must be an integer or null"
|
||||
|
||||
if not isinstance(comment["text"], string_types):
|
||||
return False, "text must be a string"
|
||||
|
||||
if len(comment["text"].rstrip()) < 3:
|
||||
return False, "text is too short (minimum length: 3)"
|
||||
|
||||
for key in ("author", "email", "website"):
|
||||
if not isinstance(comment[key], (string_types, type(None))):
|
||||
return False, "%s must be a string or null" % key
|
||||
|
||||
if len(comment["email"] or "") > 254:
|
||||
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
|
||||
|
||||
if comment["website"]:
|
||||
if len(comment["website"]) > 254:
|
||||
return False, "arbitrary length limit"
|
||||
if not Validator.isurl(comment["website"]):
|
||||
return False, "Website not Django-conform"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
class Controller(object):
|
||||
|
||||
def __init__(self, db, guard=None):
|
||||
if guard is None:
|
||||
guard = Guard(db, enabled=False)
|
||||
|
||||
self.db = db
|
||||
self.guard = guard
|
||||
|
||||
@classmethod
|
||||
def Comment(cls, rv):
|
||||
return Comment(
|
||||
id=rv[0], parent=rv[1], thread=rv[2], created=rv[3], modified=rv[4],
|
||||
mode=rv[5], remote_addr=rv[6], text=rv[7], author=rv[8], email=rv[9],
|
||||
website=rv[10], likes=rv[11], dislikes=rv[12], voters=Bloomfilter(bytearray(rv[13])))
|
||||
|
||||
def new(self, remote_addr, thread, obj, moderated=False):
|
||||
obj.setdefault("text", "")
|
||||
obj.setdefault("parent", None)
|
||||
for field in ("email", "author", "website"):
|
||||
obj.setdefault(field, None)
|
||||
|
||||
valid, reason = Validator.verify(obj)
|
||||
if not valid:
|
||||
raise Invalid(reason)
|
||||
|
||||
if self.guard.enabled:
|
||||
valid, reason = self.guard.validate(remote_addr, thread, obj)
|
||||
if not valid:
|
||||
raise Denied(reason)
|
||||
|
||||
obj["id"] = None
|
||||
obj["thread"] = thread.id
|
||||
obj["mode"] = 2 if moderated else 1
|
||||
obj["created"] = time.time()
|
||||
obj["modified"] = None
|
||||
obj["remote_addr"] = remote_addr
|
||||
|
||||
obj["likes"] = obj["dislikes"] = 0
|
||||
obj["voters"] = Bloomfilter(iterable=[remote_addr])
|
||||
|
||||
if obj["parent"] is not None:
|
||||
parent = self.get(obj["parent"])
|
||||
if parent is None:
|
||||
obj["parent"] = None
|
||||
elif parent.parent: # normalize to max depth of 1
|
||||
obj["parent"] = parent.parent
|
||||
|
||||
obj = Comment(**obj)
|
||||
_id = self.db.engine.execute(self.db.comments.insert()
|
||||
.values((obj.id, obj.parent, obj.thread, obj.created, obj.modified,
|
||||
obj.mode, obj.remote_addr, obj.text, obj.author, obj.email,
|
||||
obj.website, obj.likes, obj.dislikes, buffer(obj.voters.array)))
|
||||
).inserted_primary_key[0]
|
||||
|
||||
return obj.new(id=_id)
|
||||
|
||||
def edit(self, _id, new):
|
||||
obj = self.get(_id)
|
||||
if not obj:
|
||||
return None
|
||||
|
||||
new.setdefault("text", "")
|
||||
new.setdefault("parent", None)
|
||||
for field in ("email", "author", "website"):
|
||||
new.setdefault(field, None)
|
||||
|
||||
valid, reason = Validator.verify(new)
|
||||
if not valid:
|
||||
raise Invalid(reason)
|
||||
|
||||
obj = obj.new(text=new["text"], author=new["author"], email=new["email"],
|
||||
website=new["website"], modified=time.time())
|
||||
self.db.engine.execute(self.db.comments.update()
|
||||
.values(text=obj.text, author=obj.author, email=obj.email,
|
||||
website=obj.website, modified=obj.modified)
|
||||
.where(self.db.comments.c.id == _id))
|
||||
|
||||
return obj
|
||||
|
||||
def get(self, _id):
|
||||
"""Retrieve comment with :param id: if any.
|
||||
"""
|
||||
rv = self.db.engine.execute(
|
||||
self.db.comments.select(self.db.comments.c.id == _id)).fetchone()
|
||||
|
||||
if not rv:
|
||||
return None
|
||||
|
||||
return Controller.Comment(rv)
|
||||
|
||||
def all(self, thread, mode=1, after=0, parent='any', order_by='id', limit=None):
|
||||
stmt = (self.db.comments.select()
|
||||
.where(self.db.comments.c.thread == thread.id)
|
||||
.where(self.db.comments.c.mode.op("|")(mode) == self.db.comments.c.mode)
|
||||
.where(self.db.comments.c.created > after))
|
||||
|
||||
if parent != 'any':
|
||||
stmt = stmt.where(self.db.comments.c.parent == parent)
|
||||
|
||||
stmt.order_by(getattr(self.db.comments.c, order_by))
|
||||
|
||||
if limit:
|
||||
stmt.limit(limit)
|
||||
|
||||
return list(map(Controller.Comment, self.db.engine.execute(stmt).fetchall()))
|
||||
|
||||
def vote(self, remote_addr, _id, like):
|
||||
"""Vote with +1 or -1 on comment :param id:
|
||||
|
||||
Returns True on success (in either direction), False if :param
|
||||
remote_addr: has already voted. A comment can not be voted by its
|
||||
original author.
|
||||
"""
|
||||
obj = self.get(_id)
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
if remote_addr in obj.voters:
|
||||
return False
|
||||
|
||||
if like:
|
||||
obj = obj.new(likes=obj.likes + 1)
|
||||
else:
|
||||
obj = obj.new(dislikes=obj.dislikes + 1)
|
||||
|
||||
obj.voters.add(remote_addr)
|
||||
self.db.engine.execute(self.db.comments.update()
|
||||
.values(likes=obj.likes, dislikes=obj.dislikes,
|
||||
voters=buffer(obj.voters.array))
|
||||
.where(self.db.comments.c.id == _id))
|
||||
|
||||
return True
|
||||
|
||||
def like(self, remote_addr, _id):
|
||||
return self.vote(remote_addr, _id, like=True)
|
||||
|
||||
def dislike(self, remote_addr, _id):
|
||||
return self.vote(remote_addr, _id, like=False)
|
||||
|
||||
def delete(self, _id):
|
||||
"""
|
||||
Delete comment with :param id:
|
||||
|
||||
If the comment is referenced by another (not yet deleted) comment, the
|
||||
comment is *not* removed, but cleared and flagged as deleted.
|
||||
"""
|
||||
refs = self.db.engine.execute(
|
||||
self.db.comments.select(self.db.comments.c.id).where(
|
||||
self.db.comments.c.parent == _id)).fetchone()
|
||||
|
||||
if refs is None:
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete(self.db.comments.c.id == _id))
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete()
|
||||
.where(self.db.comments.c.mode.op("|")(4) == self.db.comments.c.mode)
|
||||
.where(not_(self.db.comments.c.id.in_(
|
||||
select([self.db.comments.c.parent]).where(
|
||||
self.db.comments.c.parent != None)))))
|
||||
return None
|
||||
|
||||
obj = self.get(_id)
|
||||
obj = obj.new(text="", author=None, email=None, website=None, mode=4)
|
||||
self.db.engine.execute(self.db.comments.update()
|
||||
.values(text=obj.text, author=obj.email, website=obj.website, mode=obj.mode)
|
||||
.where(self.db.comments.c.id == _id))
|
||||
|
||||
return obj
|
||||
|
||||
def empty(self):
|
||||
return self.db.engine.execute(
|
||||
select([func.count(self.db.comments)])).fetchone()[0] == 0
|
||||
|
||||
def count(self, *threads):
|
||||
"""Retrieve comment count for :param threads:
|
||||
"""
|
||||
|
||||
ids = [getattr(th, "id", None) for th in threads]
|
||||
|
||||
threads = dict(
|
||||
self.db.engine.execute(
|
||||
select([self.db.comments.c.thread, func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.thread.in_(ids))
|
||||
.group_by(self.db.comments.c.thread)).fetchall())
|
||||
|
||||
return [threads.get(_id, 0) for _id in ids]
|
||||
|
||||
def reply_count(self, thread, mode=5, after=0):
|
||||
|
||||
rv = self.db.engine.execute(
|
||||
select([self.db.comments.c.parent, func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.thread == thread.id)
|
||||
# .where(self.db.comments.c.mode.op("|")(mode) == mode)
|
||||
.where(self.db.comments.c.created)
|
||||
.group_by(self.db.comments.c.parent)).fetchall()
|
||||
|
||||
return dict(rv)
|
||||
|
||||
def activate(self, _id):
|
||||
"""Activate comment :param id: and return True on success.
|
||||
"""
|
||||
obj = self.get(_id)
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
i = self.db.engine.execute(self.db.comments.update()
|
||||
.values(mode=1)
|
||||
.where(self.db.comments.c.id == _id)
|
||||
.where(self.db.comments.c.mode == 2)).rowcount
|
||||
|
||||
return i > 0
|
||||
|
||||
def prune(self, delta):
|
||||
"""Remove comments still in moderation queue older than max-age.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
return self.db.engine.execute(self.db.comments
|
||||
.delete()
|
||||
.where(self.db.comments.c.mode == 2)
|
||||
.where(now - self.db.comments.c.created > delta)).rowcount
|
@ -0,0 +1,45 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from sqlalchemy.sql import select, not_
|
||||
|
||||
from isso.models import Thread
|
||||
|
||||
|
||||
class Controller(object):
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def new(self, uri, title=None):
|
||||
_id = self.db.engine.execute(
|
||||
self.db.threads.insert().values(uri=uri, title=title)
|
||||
).inserted_primary_key[0]
|
||||
|
||||
return Thread(_id, uri, title)
|
||||
|
||||
def get(self, uri):
|
||||
rv = self.db.engine.execute(
|
||||
self.db.threads.select(self.db.threads.c.uri == uri)).fetchone()
|
||||
|
||||
if not rv:
|
||||
return None
|
||||
|
||||
return Thread(*rv)
|
||||
|
||||
def delete(self, uri):
|
||||
thread = self.get(uri)
|
||||
|
||||
self.db.engine.execute(
|
||||
self.db.comments.delete().where(self.db.comments.c.thread == thread.id))
|
||||
|
||||
self.db.engine.execute(
|
||||
self.db.threads.delete().where(self.db.threads.c.id == thread.id))
|
||||
|
||||
def prune(self):
|
||||
|
||||
return self.db.engine.execute(self.db.threads
|
||||
.delete()
|
||||
.where(not_(self.db.threads.c.id.in_(
|
||||
select([self.db.comments.c.thread]))))).rowcount
|
@ -1,130 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import multiprocessing
|
||||
|
||||
try:
|
||||
import uwsgi
|
||||
except ImportError:
|
||||
uwsgi = None
|
||||
|
||||
from isso.compat import PY2K
|
||||
|
||||
if PY2K:
|
||||
import thread
|
||||
else:
|
||||
import _thread as thread
|
||||
|
||||
from werkzeug.contrib.cache import NullCache, SimpleCache
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
|
||||
class Cache:
|
||||
"""Wrapper around werkzeug's cache class, to make it compatible to
|
||||
uWSGI's cache framework.
|
||||
"""
|
||||
|
||||
def __init__(self, cache):
|
||||
self.cache = cache
|
||||
|
||||
def get(self, cache, key):
|
||||
return self.cache.get(key)
|
||||
|
||||
def set(self, cache, key, value):
|
||||
return self.cache.set(key, value)
|
||||
|
||||
def delete(self, cache, key):
|
||||
return self.cache.delete(key)
|
||||
|
||||
|
||||
class Mixin(object):
|
||||
|
||||
def __init__(self, conf):
|
||||
self.lock = threading.Lock()
|
||||
self.cache = Cache(NullCache())
|
||||
|
||||
def notify(self, subject, body, retries=5):
|
||||
pass
|
||||
|
||||
|
||||
def threaded(func):
|
||||
"""
|
||||
Decorator to execute each :param func: call in a separate thread.
|
||||
"""
|
||||
|
||||
def dec(self, *args, **kwargs):
|
||||
thread.start_new_thread(func, (self, ) + args, kwargs)
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
class ThreadedMixin(Mixin):
|
||||
|
||||
def __init__(self, conf):
|
||||
|
||||
super(ThreadedMixin, self).__init__(conf)
|
||||
|
||||
if conf.getboolean("moderation", "enabled"):
|
||||
self.purge(conf.getint("moderation", "purge-after"))
|
||||
|
||||
self.cache = Cache(SimpleCache(threshold=1024, default_timeout=3600))
|
||||
|
||||
@threaded
|
||||
def purge(self, delta):
|
||||
while True:
|
||||
with self.lock:
|
||||
self.db.comments.purge(delta)
|
||||
time.sleep(delta)
|
||||
|
||||
|
||||
class ProcessMixin(ThreadedMixin):
|
||||
|
||||
def __init__(self, conf):
|
||||
|
||||
super(ProcessMixin, self).__init__(conf)
|
||||
self.lock = multiprocessing.Lock()
|
||||
|
||||
|
||||
class uWSGICache(object):
|
||||
"""Uses uWSGI Caching Framework. INI configuration:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
cache2 = name=hash,items=1024,blocksize=32
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get(self, cache, key):
|
||||
return uwsgi.cache_get(key, cache)
|
||||
|
||||
@classmethod
|
||||
def set(self, cache, key, value):
|
||||
uwsgi.cache_set(key, value, 3600, cache)
|
||||
|
||||
@classmethod
|
||||
def delete(self, cache, key):
|
||||
uwsgi.cache_del(key, cache)
|
||||
|
||||
|
||||
class uWSGIMixin(Mixin):
|
||||
|
||||
def __init__(self, conf):
|
||||
|
||||
super(uWSGIMixin, self).__init__(conf)
|
||||
|
||||
self.lock = multiprocessing.Lock()
|
||||
self.cache = uWSGICache
|
||||
|
||||
timedelta = conf.getint("moderation", "purge-after")
|
||||
purge = lambda signum: self.db.comments.purge(timedelta)
|
||||
uwsgi.register_signal(1, "", purge)
|
||||
uwsgi.add_timer(1, timedelta)
|
||||
|
||||
# run purge once
|
||||
purge(1)
|
@ -0,0 +1,259 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import binascii
|
||||
import operator
|
||||
import threading
|
||||
|
||||
import os.path
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import Table, Column, MetaData, create_engine
|
||||
from sqlalchemy import ForeignKey, Integer, Float, String, LargeBinary
|
||||
from sqlalchemy.sql import select
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
|
||||
class Adapter(object):
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
def __init__(self, db):
|
||||
self.engine = create_engine(db, echo=False)
|
||||
self.metadata = MetaData()
|
||||
|
||||
self.comments = Table("comments", self.metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("parent", Integer),
|
||||
Column("thread", None, ForeignKey("threads.id")),
|
||||
Column("created", Float),
|
||||
Column("modified", Float),
|
||||
Column("mode", Integer),
|
||||
Column("remote_addr", String(48)), # XXX use a BigInt
|
||||
Column("text", String(65535)),
|
||||
Column("author", String(255)),
|
||||
Column("email", String(255)),
|
||||
Column("website", String(255)),
|
||||
Column("likes", Integer),
|
||||
Column("dislikes", Integer),
|
||||
Column("voters", LargeBinary(256)))
|
||||
|
||||
self.threads = Table("threads", self.metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("uri", String(255), unique=True),
|
||||
Column("title", String(255)))
|
||||
|
||||
preferences = Table("preferences", self.metadata,
|
||||
Column("key", String(255), primary_key=True),
|
||||
Column("value", String(255)))
|
||||
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.preferences = Preferences(self.engine, preferences)
|
||||
|
||||
@property
|
||||
def transaction(self):
|
||||
return self.engine.begin()
|
||||
|
||||
|
||||
class Preferences(object):
|
||||
"""A simple key-value store using SQL.
|
||||
"""
|
||||
|
||||
defaults = [
|
||||
("session-key", binascii.b2a_hex(os.urandom(24))),
|
||||
]
|
||||
|
||||
def __init__(self, engine, preferences):
|
||||
self.engine = engine
|
||||
self.preferences = preferences
|
||||
|
||||
for (key, value) in Preferences.defaults:
|
||||
if self.get(key) is None:
|
||||
self.set(key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
rv = self.engine.execute(
|
||||
select([self.preferences.c.value])
|
||||
.where(self.preferences.c.key == key)).fetchone()
|
||||
|
||||
if rv is None:
|
||||
return default
|
||||
|
||||
return rv[0]
|
||||
|
||||
def set(self, key, value):
|
||||
self.engine.execute(
|
||||
self.preferences.insert().values(
|
||||
key=key, value=value))
|
||||
|
||||
|
||||
class Transaction(object):
|
||||
"""A context manager to lock the database across processes and automatic
|
||||
rollback on failure. On success, reset the isolation level back to normal.
|
||||
|
||||
SQLite3's DEFERRED (default) transaction mode causes database corruption
|
||||
for concurrent writes to the database from multiple processes. IMMEDIATE
|
||||
ensures a global write lock, but reading is still possible.
|
||||
"""
|
||||
|
||||
def __init__(self, con):
|
||||
self.con = con
|
||||
|
||||
def __enter__(self):
|
||||
self._orig = self.con.isolation_level
|
||||
self.con.isolation_level = "IMMEDIATE"
|
||||
self.con.execute("BEGIN IMMEDIATE")
|
||||
return self.con
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
try:
|
||||
if exc_type:
|
||||
self.con.rollback()
|
||||
else:
|
||||
self.con.commit()
|
||||
finally:
|
||||
self.con.isolation_level = self._orig
|
||||
|
||||
|
||||
class SQLite3(object):
|
||||
"""SQLite3 connection pool across multiple threads. Implementation idea
|
||||
from `Peewee <https://github.com/coleifer/peewee>`_.
|
||||
"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = os.path.expanduser(db)
|
||||
self.lock = threading.Lock()
|
||||
self.local = threading.local()
|
||||
|
||||
def connect(self):
|
||||
with self.lock:
|
||||
self.local.conn = sqlite3.connect(self.db, isolation_level=None)
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
self.local.conn.close()
|
||||
self.local.conn = None
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
if isinstance(sql, (list, tuple)):
|
||||
sql = ' '.join(sql)
|
||||
|
||||
return self.connection.execute(sql, args)
|
||||
|
||||
@property
|
||||
def connection(self):
|
||||
if not hasattr(self.local, 'conn') or self.local.conn is None:
|
||||
self.connect()
|
||||
return self.local.conn
|
||||
|
||||
@property
|
||||
def transaction(self):
|
||||
return Transaction(self.connection)
|
||||
|
||||
@property
|
||||
def total_changes(self):
|
||||
return self.connection.total_changes
|
||||
|
||||
|
||||
class Foo(object):
|
||||
"""DB-dependend wrapper around SQLite3.
|
||||
|
||||
Runs migration if `user_version` is older than `MAX_VERSION` and register
|
||||
a trigger for automated orphan removal.
|
||||
"""
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
def __init__(self, conn, conf):
|
||||
self.connection = conn
|
||||
self.conf = conf
|
||||
|
||||
rv = self.execute([
|
||||
"SELECT name FROM sqlite_master"
|
||||
" WHERE type='table' AND name IN ('threads', 'comments', 'preferences')"]
|
||||
).fetchone()
|
||||
|
||||
self.preferences = Preferences(self)
|
||||
self.threads = Threads(self)
|
||||
self.comments = Comments(self)
|
||||
self.guard = Guard(self)
|
||||
|
||||
if rv is None:
|
||||
self.execute("PRAGMA user_version = %i" % Adapter.MAX_VERSION)
|
||||
else:
|
||||
self.migrate(to=Adapter.MAX_VERSION)
|
||||
|
||||
self.execute([
|
||||
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
|
||||
'AFTER DELETE ON comments',
|
||||
'BEGIN',
|
||||
' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);',
|
||||
'END'])
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.execute("PRAGMA user_version").fetchone()[0]
|
||||
|
||||
def migrate(self, to):
|
||||
|
||||
if self.version >= to:
|
||||
return
|
||||
|
||||
logger.info("migrate database from version %i to %i", self.version, to)
|
||||
|
||||
# re-initialize voters blob due a bug in the bloomfilter signature
|
||||
# which added older commenter's ip addresses to the current voters blob
|
||||
if self.version == 0:
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
|
||||
|
||||
with self.connection.transaction as con:
|
||||
con.execute('UPDATE comments SET voters=?', (bf, ))
|
||||
con.execute('PRAGMA user_version = 1')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# move [general] session-key to database
|
||||
if self.version == 1:
|
||||
|
||||
with self.connection.transaction as con:
|
||||
if self.conf.has_option("general", "session-key"):
|
||||
con.execute('UPDATE preferences SET value=? WHERE key=?', (
|
||||
self.conf.get("general", "session-key"), "session-key"))
|
||||
|
||||
con.execute('PRAGMA user_version = 2')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# limit max. nesting level to 1
|
||||
if self.version == 2:
|
||||
|
||||
first = lambda rv: list(map(operator.itemgetter(0), rv))
|
||||
|
||||
with self.connection.transaction as con:
|
||||
top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall())
|
||||
flattened = defaultdict(set)
|
||||
|
||||
for id in top:
|
||||
|
||||
ids = [id, ]
|
||||
|
||||
while ids:
|
||||
rv = first(con.execute("SELECT id FROM comments WHERE parent=?", (ids.pop(), )))
|
||||
ids.extend(rv)
|
||||
flattened[id].update(set(rv))
|
||||
|
||||
for id in flattened.keys():
|
||||
for n in flattened[id]:
|
||||
con.execute("UPDATE comments SET parent=? WHERE id=?", (id, n))
|
||||
|
||||
con.execute('PRAGMA user_version = 3')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
return self.connection.execute(sql, args)
|
@ -1,119 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import operator
|
||||
import os.path
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
from isso.db.comments import Comments
|
||||
from isso.db.threads import Threads
|
||||
from isso.db.spam import Guard
|
||||
from isso.db.preferences import Preferences
|
||||
|
||||
|
||||
class SQLite3:
|
||||
"""DB-dependend wrapper around SQLite3.
|
||||
|
||||
Runs migration if `user_version` is older than `MAX_VERSION` and register
|
||||
a trigger for automated orphan removal.
|
||||
"""
|
||||
|
||||
MAX_VERSION = 3
|
||||
|
||||
def __init__(self, path, conf):
|
||||
|
||||
self.path = os.path.expanduser(path)
|
||||
self.conf = conf
|
||||
|
||||
rv = self.execute([
|
||||
"SELECT name FROM sqlite_master"
|
||||
" WHERE type='table' AND name IN ('threads', 'comments', 'preferences')"]
|
||||
).fetchone()
|
||||
|
||||
self.preferences = Preferences(self)
|
||||
self.threads = Threads(self)
|
||||
self.comments = Comments(self)
|
||||
self.guard = Guard(self)
|
||||
|
||||
if rv is None:
|
||||
self.execute("PRAGMA user_version = %i" % SQLite3.MAX_VERSION)
|
||||
else:
|
||||
self.migrate(to=SQLite3.MAX_VERSION)
|
||||
|
||||
self.execute([
|
||||
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
|
||||
'AFTER DELETE ON comments',
|
||||
'BEGIN',
|
||||
' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);',
|
||||
'END'])
|
||||
|
||||
def execute(self, sql, args=()):
|
||||
|
||||
if isinstance(sql, (list, tuple)):
|
||||
sql = ' '.join(sql)
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
return con.execute(sql, args)
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.execute("PRAGMA user_version").fetchone()[0]
|
||||
|
||||
def migrate(self, to):
|
||||
|
||||
if self.version >= to:
|
||||
return
|
||||
|
||||
logger.info("migrate database from version %i to %i", self.version, to)
|
||||
|
||||
# re-initialize voters blob due a bug in the bloomfilter signature
|
||||
# which added older commenter's ip addresses to the current voters blob
|
||||
if self.version == 0:
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
con.execute('UPDATE comments SET voters=?', (bf, ))
|
||||
con.execute('PRAGMA user_version = 1')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# move [general] session-key to database
|
||||
if self.version == 1:
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
if self.conf.has_option("general", "session-key"):
|
||||
con.execute('UPDATE preferences SET value=? WHERE key=?', (
|
||||
self.conf.get("general", "session-key"), "session-key"))
|
||||
|
||||
con.execute('PRAGMA user_version = 2')
|
||||
logger.info("%i rows changed", con.total_changes)
|
||||
|
||||
# limit max. nesting level to 1
|
||||
if self.version == 2:
|
||||
|
||||
first = lambda rv: list(map(operator.itemgetter(0), rv))
|
||||
|
||||
with sqlite3.connect(self.path) as con:
|
||||
top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall())
|
||||
flattened = defaultdict(set)
|
||||
|
||||
for id in top:
|
||||
|
||||
ids = [id, ]
|
||||
|
||||
while ids:
|
||||
rv = first(con.execute("SELECT id FROM comments WHERE parent=?", (ids.pop(), )))
|
||||
ids.extend(rv)
|
||||
flattened[id].update(set(rv))
|
||||
|
||||
for id in flattened.keys():
|
||||
for n in flattened[id]:
|
||||
con.execute("UPDATE comments SET parent=? WHERE id=?", (id, n))
|
||||
|
||||
con.execute('PRAGMA user_version = 3')
|
||||
logger.info("%i rows changed", con.total_changes)
|
@ -1,234 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import time
|
||||
|
||||
from isso.utils import Bloomfilter
|
||||
from isso.compat import buffer
|
||||
|
||||
|
||||
class Comments:
|
||||
"""Hopefully DB-independend SQL to store, modify and retrieve all
|
||||
comment-related actions. Here's a short scheme overview:
|
||||
|
||||
| tid (thread id) | id (comment id) | parent | ... | voters | remote_addr |
|
||||
+-----------------+-----------------+--------+-----+--------+-------------+
|
||||
| 1 | 1 | null | ... | BLOB | 127.0.0.0 |
|
||||
| 1 | 2 | 1 | ... | BLOB | 127.0.0.0 |
|
||||
+-----------------+-----------------+--------+-----+--------+-------------+
|
||||
|
||||
The tuple (tid, id) is unique and thus primary key.
|
||||
"""
|
||||
|
||||
fields = ['tid', 'id', 'parent', 'created', 'modified', 'mode', 'remote_addr',
|
||||
'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters']
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS comments (',
|
||||
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,',
|
||||
' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,',
|
||||
' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,',
|
||||
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);'])
|
||||
|
||||
def add(self, uri, c):
|
||||
"""
|
||||
Add new comment to DB and return a mapping of :attribute:`fields` and
|
||||
database values.
|
||||
"""
|
||||
|
||||
if c.get("parent") is not None:
|
||||
ref = self.get(c["parent"])
|
||||
if ref.get("parent") is not None:
|
||||
c["parent"] = ref["parent"]
|
||||
|
||||
self.db.execute([
|
||||
'INSERT INTO comments (',
|
||||
' tid, parent,'
|
||||
' created, modified, mode, remote_addr,',
|
||||
' text, author, email, website, voters )',
|
||||
'SELECT',
|
||||
' threads.id, ?,',
|
||||
' ?, ?, ?, ?,',
|
||||
' ?, ?, ?, ?, ?',
|
||||
'FROM threads WHERE threads.uri = ?;'], (
|
||||
c.get('parent'),
|
||||
c.get('created') or time.time(), None, c["mode"], c['remote_addr'],
|
||||
c['text'], c.get('author'), c.get('email'), c.get('website'), buffer(
|
||||
Bloomfilter(iterable=[c['remote_addr']]).array),
|
||||
uri)
|
||||
)
|
||||
|
||||
return dict(zip(Comments.fields, self.db.execute(
|
||||
'SELECT *, MAX(c.id) FROM comments AS c INNER JOIN threads ON threads.uri = ?',
|
||||
(uri, )).fetchone()))
|
||||
|
||||
def activate(self, id):
|
||||
"""
|
||||
Activate comment id if pending.
|
||||
"""
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
' mode=1',
|
||||
'WHERE id=? AND mode=2'], (id, ))
|
||||
|
||||
def update(self, id, data):
|
||||
"""
|
||||
Update comment :param:`id` with values from :param:`data` and return
|
||||
updated comment.
|
||||
"""
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
','.join(key + '=' + '?' for key in data),
|
||||
'WHERE id=?;'],
|
||||
list(data.values()) + [id])
|
||||
|
||||
return self.get(id)
|
||||
|
||||
def get(self, id):
|
||||
"""
|
||||
Search for comment :param:`id` and return a mapping of :attr:`fields`
|
||||
and values.
|
||||
"""
|
||||
rv = self.db.execute('SELECT * FROM comments WHERE id=?', (id, )).fetchone()
|
||||
if rv:
|
||||
return dict(zip(Comments.fields, rv))
|
||||
|
||||
return None
|
||||
|
||||
def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None):
|
||||
"""
|
||||
Return comments for :param:`uri` with :param:`mode`.
|
||||
"""
|
||||
sql = [ 'SELECT comments.* FROM comments INNER JOIN threads ON',
|
||||
' threads.uri=? AND comments.tid=threads.id AND (? | comments.mode) = ?',
|
||||
' AND comments.created>?']
|
||||
|
||||
sql_args = [uri, mode, mode, after]
|
||||
|
||||
if parent != 'any':
|
||||
if parent is None:
|
||||
sql.append('AND comments.parent IS NULL')
|
||||
else:
|
||||
sql.append('AND comments.parent=?')
|
||||
sql_args.append(parent)
|
||||
|
||||
sql.append('ORDER BY ? ASC')
|
||||
sql_args.append(order_by)
|
||||
|
||||
if limit:
|
||||
sql.append('LIMIT ?')
|
||||
sql_args.append(limit)
|
||||
|
||||
rv = self.db.execute(sql, sql_args).fetchall()
|
||||
for item in rv:
|
||||
yield dict(zip(Comments.fields, item))
|
||||
|
||||
def _remove_stale(self):
|
||||
|
||||
sql = ('DELETE FROM',
|
||||
' comments',
|
||||
'WHERE',
|
||||
' mode=4 AND id NOT IN (',
|
||||
' SELECT',
|
||||
' parent',
|
||||
' FROM',
|
||||
' comments',
|
||||
' WHERE parent IS NOT NULL)')
|
||||
|
||||
while self.db.execute(sql).rowcount:
|
||||
continue
|
||||
|
||||
def delete(self, id):
|
||||
"""
|
||||
Delete a comment. There are two distinctions: a comment is referenced
|
||||
by another valid comment's parent attribute or stand-a-lone. In this
|
||||
case the comment can't be removed without losing depending comments.
|
||||
Hence, delete removes all visible data such as text, author, email,
|
||||
website sets the mode field to 4.
|
||||
|
||||
In the second case this comment can be safely removed without any side
|
||||
effects."""
|
||||
|
||||
refs = self.db.execute('SELECT * FROM comments WHERE parent=?', (id, )).fetchone()
|
||||
|
||||
if refs is None:
|
||||
self.db.execute('DELETE FROM comments WHERE id=?', (id, ))
|
||||
self._remove_stale()
|
||||
return None
|
||||
|
||||
self.db.execute('UPDATE comments SET text=? WHERE id=?', ('', id))
|
||||
self.db.execute('UPDATE comments SET mode=? WHERE id=?', (4, id))
|
||||
for field in ('author', 'website'):
|
||||
self.db.execute('UPDATE comments SET %s=? WHERE id=?' % field, (None, id))
|
||||
|
||||
self._remove_stale()
|
||||
return self.get(id)
|
||||
|
||||
def vote(self, upvote, id, remote_addr):
|
||||
"""+1 a given comment. Returns the new like count (may not change because
|
||||
the creater can't vote on his/her own comment and multiple votes from the
|
||||
same ip address are ignored as well)."""
|
||||
|
||||
rv = self.db.execute(
|
||||
'SELECT likes, dislikes, voters FROM comments WHERE id=?', (id, )) \
|
||||
.fetchone()
|
||||
|
||||
if rv is None:
|
||||
return None
|
||||
|
||||
likes, dislikes, voters = rv
|
||||
if likes + dislikes >= 142:
|
||||
return {'likes': likes, 'dislikes': dislikes}
|
||||
|
||||
bf = Bloomfilter(bytearray(voters), likes + dislikes)
|
||||
if remote_addr in bf:
|
||||
return {'likes': likes, 'dislikes': dislikes}
|
||||
|
||||
bf.add(remote_addr)
|
||||
self.db.execute([
|
||||
'UPDATE comments SET',
|
||||
' likes = likes + 1,' if upvote else 'dislikes = dislikes + 1,',
|
||||
' voters = ?'
|
||||
'WHERE id=?;'], (buffer(bf.array), id))
|
||||
|
||||
if upvote:
|
||||
return {'likes': likes + 1, 'dislikes': dislikes}
|
||||
return {'likes': likes, 'dislikes': dislikes + 1}
|
||||
|
||||
def reply_count(self, url, mode=5, after=0):
|
||||
"""
|
||||
Return comment count for main thread and all reply threads for one url.
|
||||
"""
|
||||
|
||||
sql = ['SELECT comments.parent,count(*)',
|
||||
'FROM comments INNER JOIN threads ON',
|
||||
' threads.uri=? AND comments.tid=threads.id AND',
|
||||
' (? | comments.mode = ?) AND',
|
||||
' comments.created > ?',
|
||||
'GROUP BY comments.parent']
|
||||
|
||||
return dict(self.db.execute(sql, [url, mode, mode, after]).fetchall())
|
||||
|
||||
def count(self, *urls):
|
||||
"""
|
||||
Return comment count for one ore more urls..
|
||||
"""
|
||||
|
||||
threads = dict(self.db.execute([
|
||||
'SELECT threads.uri, COUNT(comments.id) FROM comments',
|
||||
'LEFT OUTER JOIN threads ON threads.id = tid AND comments.mode = 1',
|
||||
'GROUP BY threads.uri'
|
||||
]).fetchall())
|
||||
|
||||
return [threads.get(url, 0) for url in urls]
|
||||
|
||||
def purge(self, delta):
|
||||
"""
|
||||
Remove comments older than :param:`delta`.
|
||||
"""
|
||||
self.db.execute([
|
||||
'DELETE FROM comments WHERE mode = 2 AND ? - created > ?;'
|
||||
], (time.time(), delta))
|
||||
self._remove_stale()
|
@ -1,36 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import binascii
|
||||
|
||||
|
||||
class Preferences:
|
||||
|
||||
defaults = [
|
||||
("session-key", binascii.b2a_hex(os.urandom(24))),
|
||||
]
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS preferences (',
|
||||
' key VARCHAR PRIMARY KEY, value VARCHAR',
|
||||
');'])
|
||||
|
||||
for (key, value) in Preferences.defaults:
|
||||
if self.get(key) is None:
|
||||
self.set(key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
rv = self.db.execute(
|
||||
'SELECT value FROM preferences WHERE key=?', (key, )).fetchone()
|
||||
|
||||
if rv is None:
|
||||
return default
|
||||
|
||||
return rv[0]
|
||||
|
||||
def set(self, key, value):
|
||||
self.db.execute(
|
||||
'INSERT INTO preferences (key, value) VALUES (?, ?)', (key, value))
|
@ -1,67 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class Guard:
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.conf = db.conf.section("guard")
|
||||
self.max_age = db.conf.getint("general", "max-age")
|
||||
|
||||
def validate(self, uri, comment):
|
||||
|
||||
if not self.conf.getboolean("enabled"):
|
||||
return True, ""
|
||||
|
||||
for func in (self._limit, self._spam):
|
||||
valid, reason = func(uri, comment)
|
||||
if not valid:
|
||||
return False, reason
|
||||
return True, ""
|
||||
|
||||
@classmethod
|
||||
def ids(cls, rv):
|
||||
return [str(col[0]) for col in rv]
|
||||
|
||||
def _limit(self, uri, comment):
|
||||
|
||||
# block more than :param:`ratelimit` comments per minute
|
||||
rv = self.db.execute([
|
||||
'SELECT id FROM comments WHERE remote_addr = ? AND ? - created < 60;'
|
||||
], (comment["remote_addr"], time.time())).fetchall()
|
||||
|
||||
if len(rv) >= self.conf.getint("ratelimit"):
|
||||
return False, "{0}: ratelimit exceeded ({1})".format(
|
||||
comment["remote_addr"], ', '.join(Guard.ids(rv)))
|
||||
|
||||
# block more than three comments as direct response to the post
|
||||
if comment["parent"] is None:
|
||||
rv = self.db.execute([
|
||||
'SELECT id FROM comments WHERE',
|
||||
' tid = (SELECT id FROM threads WHERE uri = ?)',
|
||||
'AND remote_addr = ?',
|
||||
'AND parent IS NULL;'
|
||||
], (uri, comment["remote_addr"])).fetchall()
|
||||
|
||||
if len(rv) >= self.conf.getint("direct-reply"):
|
||||
return False, "%i direct responses to %s" % (len(rv), uri)
|
||||
|
||||
elif self.conf.getboolean("reply-to-self") == False:
|
||||
rv = self.db.execute([
|
||||
'SELECT id FROM comments WHERE'
|
||||
' remote_addr = ?',
|
||||
'AND id = ?',
|
||||
'AND ? - created < ?'
|
||||
], (comment["remote_addr"], comment["parent"],
|
||||
time.time(), self.max_age)).fetchall()
|
||||
|
||||
if len(rv) > 0:
|
||||
return False, "edit time frame is still open"
|
||||
|
||||
return True, ""
|
||||
|
||||
def _spam(self, uri, comment):
|
||||
return True, ""
|
@ -1,30 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
|
||||
def Thread(id, uri, title):
|
||||
return {
|
||||
"id": id,
|
||||
"uri": uri,
|
||||
"title": title
|
||||
}
|
||||
|
||||
|
||||
class Threads(object):
|
||||
|
||||
def __init__(self, db):
|
||||
|
||||
self.db = db
|
||||
self.db.execute([
|
||||
'CREATE TABLE IF NOT EXISTS threads (',
|
||||
' id INTEGER PRIMARY KEY, uri VARCHAR(256) UNIQUE, title VARCHAR(256))'])
|
||||
|
||||
def __contains__(self, uri):
|
||||
return self.db.execute("SELECT title FROM threads WHERE uri=?", (uri, )) \
|
||||
.fetchone() is not None
|
||||
|
||||
def __getitem__(self, uri):
|
||||
return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone())
|
||||
|
||||
def new(self, uri, title):
|
||||
self.db.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
|
||||
return self[uri]
|
@ -0,0 +1 @@
|
||||
../share/isso.conf
|
@ -0,0 +1,45 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import operator
|
||||
|
||||
|
||||
class Thread(object):
|
||||
|
||||
__slots__ = ['id', 'uri', 'title']
|
||||
|
||||
def __init__(self, id, uri, title=None):
|
||||
self.id = id
|
||||
self.uri = uri
|
||||
self.title = title
|
||||
|
||||
|
||||
class Comment(object):
|
||||
|
||||
__slots__ = [
|
||||
'id', 'parent', 'thread', 'created', 'modified', 'mode', 'remote_addr',
|
||||
'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters']
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for attr in Comment.__slots__:
|
||||
try:
|
||||
setattr(self, attr, kwargs.pop(attr))
|
||||
except KeyError:
|
||||
raise TypeError("missing '{0}' argument".format(attr))
|
||||
|
||||
if kwargs:
|
||||
raise TypeError("unexpected argument '{0}'".format(*kwargs.popitem()))
|
||||
|
||||
def new(self, **kwargs):
|
||||
kw = dict(zip(Comment.__slots__, operator.attrgetter(*Comment.__slots__)(self)))
|
||||
kw.update(kwargs)
|
||||
return Comment(**kw)
|
||||
|
||||
@property
|
||||
def moderated(self):
|
||||
return self.mode | 2 == self.mode
|
||||
|
||||
@property
|
||||
def deleted(self):
|
||||
return self.mode | 4 == self.mode
|
@ -0,0 +1,194 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals, division
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import math
|
||||
import bisect
|
||||
import functools
|
||||
|
||||
import time
|
||||
|
||||
try:
|
||||
import queue
|
||||
except ImportError:
|
||||
import Queue as queue
|
||||
|
||||
from isso.tasks import Cron
|
||||
from isso.compat import iteritems
|
||||
|
||||
logger = logging.getLogger("isso")
|
||||
|
||||
Full = queue.Full
|
||||
Empty = queue.Empty
|
||||
|
||||
|
||||
class Retry(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Timeout(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Message(object):
|
||||
"""Queue payload sortable by time.
|
||||
|
||||
:param type: task type
|
||||
:param data: task payload
|
||||
:param delay: initial delay before the job gets executed
|
||||
:param wait: subsequent delays for retrying
|
||||
"""
|
||||
|
||||
def __init__(self, type, data, delay=0, wait=0):
|
||||
self.type = type
|
||||
self.data = data
|
||||
|
||||
self.wait = wait
|
||||
self.timestamp = time.time() + delay
|
||||
|
||||
def __le__(self, other):
|
||||
return self.timestamp + self.wait <= other.timestamp + other.wait
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.type == other.type and self.data == other.data
|
||||
|
||||
def __repr__(self):
|
||||
return "<Message {0}: {1}>".format(self.type, self.data)
|
||||
|
||||
|
||||
class Queue(object):
|
||||
"""An in-memory queue with requeuing abilities.
|
||||
|
||||
:param maxlen: upperbound limit
|
||||
:param timeout: maximum retry interval after which a :func:`retry` call
|
||||
raises :exception:`Timeout` (defaults to ~34 min)
|
||||
"""
|
||||
|
||||
def __init__(self, maxlen=-1, timeout=2**10):
|
||||
self.queue = []
|
||||
self.maxlen = maxlen
|
||||
self.timeout = timeout
|
||||
|
||||
# lock destructive queue operations
|
||||
self.mutex = threading.Lock()
|
||||
|
||||
def put(self, item):
|
||||
with self.mutex:
|
||||
if -1 < self.maxlen < len(self.queue) + 1:
|
||||
raise queue.Full
|
||||
|
||||
bisect.insort(self.queue, item)
|
||||
|
||||
def get(self):
|
||||
with self.mutex:
|
||||
try:
|
||||
msg = self.queue.pop(0)
|
||||
except IndexError:
|
||||
raise queue.Empty
|
||||
|
||||
if msg.timestamp + msg.wait <= time.time():
|
||||
return msg
|
||||
|
||||
self.queue.insert(0, msg)
|
||||
|
||||
raise queue.Empty
|
||||
|
||||
def retry(self, msg):
|
||||
self.put(Queue.delay(msg, self.timeout))
|
||||
|
||||
def requeue(self, msg, timedelta):
|
||||
self.put(Message(msg.type, msg.data, timedelta.total_seconds()))
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
with self.mutex:
|
||||
return len(self.queue)
|
||||
|
||||
@classmethod
|
||||
def delay(cls, msg, timeout, delayfunc=lambda i: max(1, i * 2)):
|
||||
wait = delayfunc(msg.wait)
|
||||
if wait >= timeout:
|
||||
raise Timeout("Exceeded time limit of {0}".format(timeout))
|
||||
return Message(msg.type, msg.data, 0, wait)
|
||||
|
||||
|
||||
class Worker(threading.Thread):
|
||||
"""Thread that pulls data from the queue, does the actual work. If the queue
|
||||
is empty, sleep for longer intervals (see :func:`wait` for details)
|
||||
|
||||
On startup, all recurring tasks are automatically queued with zero delay
|
||||
to run at least once.
|
||||
|
||||
A task may throw :exception Retry: to indicate a expected failure (e.g.
|
||||
network not reachable) and asking to retry later.
|
||||
|
||||
:param queue: a Queue
|
||||
:param targets: a mapping of task names and the actual task objects"""
|
||||
|
||||
interval = 0.1
|
||||
|
||||
def __init__(self, queue, targets):
|
||||
super(Worker, self).__init__()
|
||||
|
||||
self.alive = True
|
||||
self.queue = queue
|
||||
self.targets = targets
|
||||
|
||||
for name, target in iteritems(targets):
|
||||
if isinstance(target, Cron):
|
||||
queue.put(Message(name, None))
|
||||
|
||||
def run(self):
|
||||
while self.alive:
|
||||
try:
|
||||
payload = self.queue.get()
|
||||
except queue.Empty:
|
||||
self.wait(10)
|
||||
else:
|
||||
task = self.targets.get(payload.type)
|
||||
if task is None:
|
||||
logger.warn("No such task '%s'", payload.type)
|
||||
continue
|
||||
try:
|
||||
logger.debug("Executing {0} with '{1}'".format(
|
||||
payload.type, json.dumps(payload.data)))
|
||||
task.run(payload.data)
|
||||
except Retry:
|
||||
try:
|
||||
self.queue.retry(payload)
|
||||
except Timeout:
|
||||
logger.exception("Uncaught exception while retrying "
|
||||
"%s.run", task)
|
||||
except Exception:
|
||||
logger.exception("Uncaught exception while executing "
|
||||
"%s.run", task)
|
||||
finally:
|
||||
if isinstance(task, Cron):
|
||||
self.queue.requeue(payload, task.timedelta)
|
||||
|
||||
def join(self, timeout=None):
|
||||
self.alive = False
|
||||
super(Worker, self).join(timeout)
|
||||
|
||||
def wait(self, seconds):
|
||||
"""Sleep for :param seconds: but split into :var interval: sleeps to
|
||||
be interruptable.
|
||||
"""
|
||||
f, i = math.modf(seconds / Worker.interval)
|
||||
|
||||
for x in range(int(i)):
|
||||
if self.alive:
|
||||
time.sleep(Worker.interval)
|
||||
|
||||
if self.alive:
|
||||
time.sleep(f * Worker.interval)
|
||||
|
||||
|
||||
from .sa import SAQueue
|
||||
|
||||
__all__ = ["Full", "Empty", "Retry", "Timeout", "Message", "Queue", "SAQueue"]
|
@ -0,0 +1,68 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from sqlalchemy import Table, Column, MetaData, create_engine
|
||||
from sqlalchemy import Integer, Float, String, LargeBinary
|
||||
from sqlalchemy.sql import select, func
|
||||
|
||||
from . import Queue, Full, Empty, Message
|
||||
|
||||
|
||||
pickle = lambda val: json.dumps(val).encode("utf-8")
|
||||
unpickle = lambda val: json.loads(val.decode("utf-8"))
|
||||
|
||||
|
||||
class SAQueue(Queue):
|
||||
"""Implements a shared queue using SQLAlchemy Core
|
||||
"""
|
||||
|
||||
def __init__(self, db, maxlen=-1, timeout=2**10):
|
||||
super(SAQueue, self).__init__(maxlen, timeout)
|
||||
self.metadata = MetaData()
|
||||
self.engine = create_engine(db)
|
||||
self.queue = Table("queue", self.metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("type", String(16)),
|
||||
Column("data", LargeBinary(65535)),
|
||||
Column("timestamp", Float),
|
||||
Column("wait", Float))
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.engine.execute(self.queue.delete())
|
||||
|
||||
def put(self, item):
|
||||
with self.engine.begin() as con:
|
||||
count = self._size(con) + 1
|
||||
if -1 < self.maxlen < count:
|
||||
raise Full
|
||||
|
||||
con.execute(self.queue.insert().values(
|
||||
type=item.type, data=pickle(item.data),
|
||||
timestamp=item.timestamp, wait=item.wait))
|
||||
|
||||
def get(self):
|
||||
with self.engine.begin() as con:
|
||||
obj = con.execute(
|
||||
select([self.queue.c.id, self.queue.c.type, self.queue.c.data])
|
||||
.where(time.time() > self.queue.c.timestamp + self.queue.c.wait)
|
||||
.order_by(self.queue.c.timestamp)
|
||||
.limit(1)).fetchone()
|
||||
|
||||
if not obj:
|
||||
raise Empty
|
||||
|
||||
_id, _type, data = obj
|
||||
con.execute(self.queue.delete(self.queue.c.id == _id))
|
||||
|
||||
return Message(_type, unpickle(data))
|
||||
|
||||
def _size(self, con):
|
||||
return con.execute(select([func.count(self.queue)])).fetchone()[0]
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._size(self.engine.connect())
|
@ -0,0 +1,56 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy.sql import select, func
|
||||
|
||||
|
||||
class Guard(object):
|
||||
|
||||
def __init__(self, db, enabled, ratelimit=2, direct_reply=3,
|
||||
reply_to_self=False, max_age=15*60):
|
||||
self.db = db
|
||||
self.enabled = enabled
|
||||
|
||||
self.ratelimit = ratelimit
|
||||
self.direct_reply = direct_reply
|
||||
self.reply_to_self = reply_to_self
|
||||
self.max_age = max_age
|
||||
|
||||
def validate(self, remote_addr, thread, comment):
|
||||
|
||||
now = time.time()
|
||||
|
||||
# block more than :param:`ratelimit` comments per minute
|
||||
count = self.db.engine.execute(
|
||||
select([func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.remote_addr == remote_addr)
|
||||
.where(now - self.db.comments.c.created < 60)).fetchone()[0]
|
||||
|
||||
if count >= self.ratelimit > -1:
|
||||
return False, "%s: ratelimit exceeded" % remote_addr
|
||||
|
||||
# block more than three comments as direct response to the post
|
||||
if comment["parent"] is None:
|
||||
count = self.db.engine.execute(
|
||||
select([func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.thread == thread.id)
|
||||
.where(self.db.comments.c.remote_addr == remote_addr)
|
||||
.where(self.db.comments.c.parent == None)).fetchone()[0]
|
||||
|
||||
if count >= self.direct_reply:
|
||||
return False, "only {0} direct response(s) to {1}".format(
|
||||
count, thread.uri)
|
||||
|
||||
elif not self.reply_to_self:
|
||||
count = self.db.engine.execute(
|
||||
select([func.count(self.db.comments)])
|
||||
.where(self.db.comments.c.remote_addr == remote_addr)
|
||||
.where(now - self.db.comments.c.created < self.max_age)).fetchone()[0]
|
||||
|
||||
if count > 0:
|
||||
return False, "editing frame is still open"
|
||||
|
||||
return True, ""
|
@ -0,0 +1,58 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import abc
|
||||
import datetime
|
||||
|
||||
|
||||
class Task(object):
|
||||
"""A task. Override :func:`run` with custom functionality. Tasks itself
|
||||
may cause blocking I/O but should terminate.
|
||||
"""
|
||||
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
@abc.abstractmethod
|
||||
def run(self, data):
|
||||
return
|
||||
|
||||
|
||||
class Cron(Task):
|
||||
"""Crons are tasks which are re-scheduled after each execution."""
|
||||
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.timedelta = datetime.timedelta(*args, **kwargs)
|
||||
|
||||
def run(self, data):
|
||||
return
|
||||
|
||||
|
||||
from . import db, http, mail
|
||||
|
||||
|
||||
class Jobs(dict):
|
||||
"""Obviously a poor man's factory"""
|
||||
|
||||
available = {
|
||||
"db-purge": db.Purge,
|
||||
"http-fetch": http.Fetch,
|
||||
"mail-send": mail.Send
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super(Jobs, self).__init__()
|
||||
|
||||
def register(self, name, *args, **kwargs):
|
||||
if name in self:
|
||||
return
|
||||
|
||||
try:
|
||||
cls = Jobs.available[name]
|
||||
except KeyError:
|
||||
raise RuntimeError("No such task '%s'" % name)
|
||||
|
||||
self[name] = cls(*args, **kwargs)
|
||||
|
||||
|
||||
__all__ = ["Job", "Cron", "Jobs"]
|
@ -0,0 +1,27 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("isso")
|
||||
|
||||
from isso.controllers import threads, comments
|
||||
|
||||
from . import Cron
|
||||
|
||||
|
||||
class Purge(Cron):
|
||||
|
||||
def __init__(self, db, after):
|
||||
super(Purge, self).__init__(hours=1)
|
||||
self.after = after
|
||||
|
||||
self.threads = threads.Controller(db)
|
||||
self.comments = comments.Controller(db)
|
||||
|
||||
def run(self, data):
|
||||
rows = self.comments.prune(self.after)
|
||||
if rows:
|
||||
log.info("removed %s comment(s)", rows)
|
||||
|
||||
rows = self.threads.prune()
|
||||
if rows:
|
||||
log.info("removed %s thread(s)", rows)
|
@ -0,0 +1,12 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from . import Task
|
||||
|
||||
|
||||
class Fetch(Task):
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def run(self, data):
|
||||
raise NotImplemented
|
@ -0,0 +1,59 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import sys
|
||||
import smtplib
|
||||
|
||||
from email.utils import formatdate
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from . import Task
|
||||
|
||||
|
||||
class Send(Task):
|
||||
|
||||
def __init__(self, section):
|
||||
self.conf = section
|
||||
self.client = None
|
||||
|
||||
def __enter__(self):
|
||||
klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP)
|
||||
self.client = klass(host=self.conf.get('host'),
|
||||
port=self.conf.getint('port'),
|
||||
timeout=self.conf.getint('timeout'))
|
||||
|
||||
if self.conf.get('security') == 'starttls':
|
||||
if sys.version_info >= (3, 4):
|
||||
import ssl
|
||||
self.client.starttls(context=ssl.create_default_context())
|
||||
else:
|
||||
self.client.starttls()
|
||||
|
||||
if self.conf.get('username') and self.conf.get('password'):
|
||||
self.client.login(self.conf.get('username'),
|
||||
self.conf.get('password'))
|
||||
|
||||
return self.client
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.client is not None:
|
||||
self.client.quit()
|
||||
self.client = None
|
||||
|
||||
def run(self, data):
|
||||
subject = data["subject"]
|
||||
body = data["body"]
|
||||
|
||||
from_addr = self.conf.get("from")
|
||||
to_addr = self.conf.get("to")
|
||||
|
||||
msg = MIMEText(body, 'plain', 'utf-8')
|
||||
msg['From'] = from_addr
|
||||
msg['To'] = to_addr
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Subject'] = Header(subject, 'utf-8')
|
||||
|
||||
with self as con:
|
||||
con.sendmail(from_addr, to_addr, msg.as_string())
|
@ -0,0 +1,241 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from isso import db
|
||||
|
||||
from isso.models import Thread
|
||||
from isso.controllers import comments
|
||||
|
||||
IP = "127.0.0.1"
|
||||
TH = Thread(1, "/")
|
||||
|
||||
|
||||
class TestValidator(unittest.TestCase):
|
||||
|
||||
def test_validator(self):
|
||||
|
||||
default = dict(zip(["parent", "text", "author", "email", "website"], [None]*5))
|
||||
new = lambda **z: dict(default, **z)
|
||||
verify = lambda data: comments.Validator.verify(data)[0]
|
||||
|
||||
# invalid types
|
||||
self.assertFalse(verify(new(text=None)))
|
||||
self.assertFalse(verify(new(parent="xxx")))
|
||||
for key in ("author", "website", "email"):
|
||||
self.assertFalse(verify(new(text="...", **{key: 3.14})))
|
||||
|
||||
# text too short and/or blank
|
||||
for text in ("", "\n\n\n"):
|
||||
self.assertFalse(verify(new(text=text)))
|
||||
|
||||
# email/website length
|
||||
self.assertTrue(verify(new(text="...", email="*"*254)))
|
||||
self.assertTrue(verify(new(text="...", website="google.de/" + "a"*128)))
|
||||
|
||||
self.assertFalse(verify(new(text="...", email="*"*1024)))
|
||||
self.assertFalse(verify(new(text="...", website="google.de/" + "*"*1024)))
|
||||
|
||||
# invalid url
|
||||
self.assertFalse(verify(new(text="...", website="spam")))
|
||||
|
||||
def test_isurl(self):
|
||||
isurl = comments.Validator.isurl
|
||||
|
||||
# valid website url
|
||||
self.assertTrue(isurl("example.tld"))
|
||||
self.assertTrue(isurl("http://example.tld"))
|
||||
self.assertTrue(isurl("https://example.tld"))
|
||||
self.assertTrue(isurl("https://example.tld:1337/"))
|
||||
self.assertTrue(isurl("https://example.tld:1337/foobar"))
|
||||
self.assertTrue(isurl("https://example.tld:1337/foobar?p=1#isso-thread"))
|
||||
|
||||
self.assertFalse(isurl("ftp://example.tld/"))
|
||||
self.assertFalse(isurl("tel:+1234567890"))
|
||||
self.assertFalse(isurl("+1234567890"))
|
||||
self.assertFalse(isurl("spam"))
|
||||
|
||||
|
||||
class TestController(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.controller = comments.Controller(db.Adapter("sqlite:///:memory:"))
|
||||
|
||||
def test_new(self):
|
||||
obj = self.controller.new(IP, TH, dict(text="Здравствуй, мир!"))
|
||||
|
||||
self.assertEqual(obj.id, 1)
|
||||
self.assertEqual(obj.text, "Здравствуй, мир!")
|
||||
self.assertLess(obj.created, time.time())
|
||||
self.assertIn(IP, obj.voters)
|
||||
self.assertFalse(obj.moderated)
|
||||
self.assertFalse(obj.deleted)
|
||||
|
||||
sec = self.controller.new(IP, TH, dict(text="Ohai"), moderated=True)
|
||||
self.assertEqual(sec.id, 2)
|
||||
self.assertEqual(sec.text, "Ohai")
|
||||
self.assertTrue(sec.moderated)
|
||||
self.assertFalse(sec.deleted)
|
||||
|
||||
self.assertRaises(comments.Invalid, self.controller.new, IP, TH, dict())
|
||||
|
||||
def test_create_invalid_parent(self):
|
||||
a = self.controller.new(IP, TH, dict(text="..."))
|
||||
b = self.controller.new(IP, TH, dict(text="...", parent=a.id))
|
||||
c = self.controller.new(IP, TH, dict(text="...", parent=b.id))
|
||||
|
||||
# automatic insertion to a maximum nesting level of 1
|
||||
self.assertEqual(c.parent, b.parent)
|
||||
|
||||
# remove invalid reference
|
||||
d = self.controller.new(IP, TH, dict(text="...", parent=42))
|
||||
self.assertIsNone(d.parent)
|
||||
|
||||
def test_edit(self):
|
||||
a = self.controller.new(IP, TH, dict(text="Hello!"))
|
||||
z = self.controller.new(IP, TH, dict(text="Dummy"))
|
||||
|
||||
a = self.controller.edit(a.id, dict(
|
||||
text="Hello, World!", author="Hans", email="123", website="http://example.tld/"))
|
||||
b = self.controller.get(a.id)
|
||||
|
||||
self.assertEqual(a.text, "Hello, World!")
|
||||
self.assertEqual(a.author, "Hans")
|
||||
self.assertEqual(a.email, "123")
|
||||
self.assertEqual(a.website, "http://example.tld/")
|
||||
|
||||
for attr in ("text", "author", "email", "website"):
|
||||
self.assertEqual(getattr(a, attr), getattr(b, attr))
|
||||
|
||||
self.assertEqual(self.controller.get(z.id).text, z.text)
|
||||
|
||||
# edit invalid data
|
||||
self.assertRaises(comments.Invalid, self.controller.edit, a.id, dict(text=""))
|
||||
|
||||
# edit invalid comment
|
||||
self.assertIsNone(self.controller.edit(23, dict(text="...")))
|
||||
|
||||
def test_get(self):
|
||||
obj = self.controller.get(23)
|
||||
self.assertIsNone(obj)
|
||||
|
||||
self.controller.new(IP, TH, dict(text="..."))
|
||||
obj = self.controller.get(1)
|
||||
|
||||
self.assertEqual(obj.id, 1)
|
||||
self.assertEqual(obj.thread, 1)
|
||||
|
||||
def test_all(self):
|
||||
foo, bar = Thread(0, "/foo"), Thread(1, "/bar")
|
||||
args = ("One", "Two", "three")
|
||||
|
||||
for text in args:
|
||||
self.controller.new(IP, foo, dict(text=text))
|
||||
|
||||
# control group
|
||||
self.controller.new(IP, bar, dict(text="..."))
|
||||
|
||||
rv = self.controller.all(foo)
|
||||
self.assertEqual(len(rv), 3)
|
||||
|
||||
for text, obj in zip(args, rv):
|
||||
self.assertEqual(text, obj.text)
|
||||
|
||||
def test_delete(self):
|
||||
for _ in range(3):
|
||||
self.controller.new(IP, TH, dict(text="..."))
|
||||
|
||||
for n in range(3):
|
||||
self.controller.delete(n + 1)
|
||||
self.assertIsNone(self.controller.get(n+1))
|
||||
|
||||
# delete invalid comment
|
||||
self.assertIsNone(self.controller.delete(23))
|
||||
|
||||
def test_delete_nested(self):
|
||||
p = self.controller.new(IP, TH, dict(text="parent"))
|
||||
c1 = self.controller.new(IP, TH, dict(text="child", parent=p.id))
|
||||
c2 = self.controller.new(IP, TH, dict(text="child", parent=p.id))
|
||||
|
||||
self.controller.delete(p.id)
|
||||
p = self.controller.get(p.id)
|
||||
|
||||
self.assertIsNotNone(p)
|
||||
self.assertTrue(p.deleted)
|
||||
self.assertEqual(p.text, "")
|
||||
|
||||
self.controller.delete(c1.id)
|
||||
|
||||
self.assertIsNone(self.controller.get(c1.id))
|
||||
self.assertIsNotNone(self.controller.get(c2.id))
|
||||
self.assertIsNotNone(self.controller.get(p.id))
|
||||
|
||||
self.controller.delete(c2.id)
|
||||
self.assertIsNone(self.controller.get(p.id))
|
||||
|
||||
def test_empty(self):
|
||||
self.assertTrue(self.controller.empty())
|
||||
self.controller.new(IP, TH, dict(text="..."))
|
||||
self.assertFalse(self.controller.empty())
|
||||
|
||||
def test_count(self):
|
||||
threads = [Thread(0, "a"), None, Thread(1, "c")]
|
||||
counter = [1, 0, 2]
|
||||
|
||||
self.assertEqual(self.controller.count(*threads), [0, 0, 0])
|
||||
|
||||
for thread, count in zip(threads, counter):
|
||||
if thread is None:
|
||||
continue
|
||||
for _ in range(count):
|
||||
self.controller.new(IP, thread, dict(text="..."))
|
||||
|
||||
self.assertEqual(self.controller.count(*threads), counter)
|
||||
|
||||
def test_votes(self):
|
||||
author = "127.0.0.1"
|
||||
foo, bar = "1.2.3.4", "1.3.3.7"
|
||||
|
||||
c = self.controller.new(author, TH, dict(text="..."))
|
||||
self.assertEqual(c.likes, 0)
|
||||
self.assertEqual(c.dislikes, 0)
|
||||
|
||||
# author can not vote on own comment
|
||||
self.assertFalse(self.controller.like(author, c.id))
|
||||
|
||||
# but others can (at least once)
|
||||
self.assertTrue(self.controller.like(foo, c.id))
|
||||
self.assertTrue(self.controller.dislike(bar, c.id))
|
||||
|
||||
self.assertFalse(self.controller.like(foo, c.id))
|
||||
self.assertFalse(self.controller.like(bar, c.id))
|
||||
|
||||
c = self.controller.get(c.id)
|
||||
self.assertEqual(c.likes, 1)
|
||||
self.assertEqual(c.dislikes, 1)
|
||||
|
||||
# vote a non-existent comment
|
||||
self.assertFalse(self.controller.like(foo, 23))
|
||||
|
||||
def test_activation(self):
|
||||
c = self.controller.new(IP, TH, dict(text="..."), moderated=True)
|
||||
self.assertEqual(len(self.controller.all(TH)), 0)
|
||||
|
||||
self.assertTrue(self.controller.activate(c.id))
|
||||
self.assertFalse(self.controller.activate(c.id))
|
||||
self.assertEqual(len(self.controller.all(TH)), 1)
|
||||
|
||||
# invalid comment returns False
|
||||
self.assertFalse(self.controller.activate(23))
|
||||
|
||||
def test_prune(self):
|
||||
|
||||
c = self.controller.new(IP, TH, dict(text="..."), moderated=True)
|
||||
self.assertEqual(self.controller.prune(42), 0)
|
||||
self.assertIsNotNone(self.controller.get(c.id))
|
||||
|
||||
self.assertEqual(self.controller.prune(0), 1)
|
||||
self.assertIsNone(self.controller.get(c.id))
|
@ -0,0 +1,67 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from isso import db
|
||||
from isso.controllers import comments, threads
|
||||
|
||||
|
||||
class TestController(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
_db = db.Adapter("sqlite:///:memory:")
|
||||
self.comments = comments.Controller(_db)
|
||||
self.threads = threads.Controller(_db)
|
||||
|
||||
def test_new(self):
|
||||
thread = self.threads.new("/", None)
|
||||
|
||||
self.assertEqual(thread.id, 1)
|
||||
self.assertEqual(thread.uri, "/")
|
||||
self.assertEqual(thread.title, None)
|
||||
|
||||
def test_get(self):
|
||||
self.assertIsNone(self.threads.get("/"))
|
||||
|
||||
th = self.threads.get(self.threads.new("/", None).uri)
|
||||
self.assertIsNotNone(th)
|
||||
|
||||
self.assertEqual(th.id, 1)
|
||||
self.assertEqual(th.uri, "/")
|
||||
self.assertEqual(th.title, None)
|
||||
|
||||
def test_delete(self):
|
||||
th = self.threads.new("/", None)
|
||||
self.threads.delete(th.uri)
|
||||
|
||||
self.assertIsNone(self.threads.get(th.uri))
|
||||
|
||||
def test_delete_removes_comments(self):
|
||||
th = self.threads.new("/", None)
|
||||
cg = self.threads.new("/control/group", None)
|
||||
|
||||
for _ in range(3):
|
||||
self.comments.new("127.0.0.1", th, dict(text="..."))
|
||||
self.comments.new("127.0.0.1", cg, dict(text="..."))
|
||||
|
||||
self.assertEqual(self.comments.count(th), [3])
|
||||
self.assertEqual(self.comments.count(cg), [3])
|
||||
|
||||
# now remove the thread
|
||||
self.threads.delete(th.uri)
|
||||
|
||||
self.assertEqual(self.comments.count(th), [0])
|
||||
self.assertEqual(self.comments.count(cg), [3])
|
||||
|
||||
def test_prune_empty_threads(self):
|
||||
th = self.threads.new("/", None)
|
||||
comment = self.comments.new("127.0.0.1", th, dict(text="..."))
|
||||
|
||||
self.assertEqual(self.threads.prune(), 0)
|
||||
self.assertIsNotNone(self.threads.get(th.uri))
|
||||
|
||||
self.comments.delete(comment.id)
|
||||
self.assertEqual(self.threads.prune(), 1)
|
||||
self.assertIsNone(self.threads.get(th.uri))
|
@ -1,41 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import json
|
||||
|
||||
from werkzeug.test import Client
|
||||
|
||||
|
||||
class FakeIP(object):
|
||||
|
||||
def __init__(self, app, ip):
|
||||
self.app = app
|
||||
self.ip = ip
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
environ['REMOTE_ADDR'] = self.ip
|
||||
return self.app(environ, start_response)
|
||||
|
||||
|
||||
class JSONClient(Client):
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
kwargs.setdefault('content_type', 'application/json')
|
||||
return super(JSONClient, self).open(*args, **kwargs)
|
||||
|
||||
|
||||
class Dummy:
|
||||
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def read(self):
|
||||
return ''
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
curl = lambda method, host, path: Dummy()
|
||||
loads = lambda data: json.loads(data.decode('utf-8'))
|
@ -0,0 +1,58 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from isso.compat import text_type as str
|
||||
|
||||
from isso.cache import Cache, SACache
|
||||
|
||||
ns = "test"
|
||||
|
||||
|
||||
class TestCache(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cache = Cache(threshold=8)
|
||||
|
||||
def test_cache(self):
|
||||
cache = self.cache
|
||||
|
||||
cache.delete(ns, "foo")
|
||||
self.assertIsNone(cache.get(ns, "foo"))
|
||||
|
||||
cache.set(ns, "foo", "bar")
|
||||
self.assertEqual(cache.get(ns, "foo"), "bar")
|
||||
|
||||
cache.delete(ns, "foo")
|
||||
self.assertIsNone(cache.get(ns, "foo"))
|
||||
|
||||
def test_full(self):
|
||||
cache = self.cache
|
||||
|
||||
cache.set(ns, "foo", "bar")
|
||||
|
||||
for i in range(7):
|
||||
cache.set(ns, str(i), "Spam!")
|
||||
|
||||
for i in range(7):
|
||||
self.assertEqual(cache.get(ns, str(i)), "Spam!")
|
||||
|
||||
self.assertIsNotNone(cache.get(ns, "foo"))
|
||||
|
||||
cache.set(ns, "bar", "baz")
|
||||
self.assertIsNone(cache.get(ns, "foo"))
|
||||
|
||||
def test_primitives(self):
|
||||
cache = self.cache
|
||||
|
||||
for val in (None, True, [1, 2, 3], {"bar": "baz"}):
|
||||
cache.set(ns, "val", val)
|
||||
self.assertEqual(cache.get(ns, "val"), val, val.__class__.__name__)
|
||||
|
||||
|
||||
class TestSQLite3Cache(TestCache):
|
||||
|
||||
def setUp(self):
|
||||
self.cache = SACache("sqlite:///:memory:", threshold=8)
|
@ -1,441 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
import unittest2 as unittest
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
from urllib import urlencode
|
||||
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from isso import Isso, core, config, dist
|
||||
from isso.utils import http
|
||||
from isso.views import comments
|
||||
|
||||
from isso.compat import iteritems
|
||||
|
||||
from fixtures import curl, loads, FakeIP, JSONClient
|
||||
http.curl = curl
|
||||
|
||||
|
||||
class TestComments(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
fd, self.path = tempfile.mkstemp()
|
||||
conf = config.load(os.path.join(dist.location, "share", "isso.conf"))
|
||||
conf.set("general", "dbpath", self.path)
|
||||
conf.set("guard", "enabled", "off")
|
||||
conf.set("hash", "algorithm", "none")
|
||||
|
||||
class App(Isso, core.Mixin):
|
||||
pass
|
||||
|
||||
self.app = App(conf)
|
||||
self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
|
||||
|
||||
self.client = JSONClient(self.app, Response)
|
||||
self.get = self.client.get
|
||||
self.put = self.client.put
|
||||
self.post = self.client.post
|
||||
self.delete = self.client.delete
|
||||
|
||||
def tearDown(self):
|
||||
os.unlink(self.path)
|
||||
|
||||
def testGet(self):
|
||||
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
r = self.get('/id/1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
|
||||
self.assertEqual(rv['id'], 1)
|
||||
self.assertEqual(rv['text'], '<p>Lorem ipsum ...</p>')
|
||||
|
||||
def testCreate(self):
|
||||
|
||||
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
self.assertIn("Set-Cookie", rv.headers)
|
||||
|
||||
rv = loads(rv.data)
|
||||
|
||||
self.assertEqual(rv["mode"], 1)
|
||||
self.assertEqual(rv["text"], '<p>Lorem ipsum ...</p>')
|
||||
|
||||
def textCreateWithNonAsciiText(self):
|
||||
|
||||
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Здравствуй, мир!'}))
|
||||
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
rv = loads(rv.data)
|
||||
|
||||
self.assertEqual(rv["mode"], 1)
|
||||
self.assertEqual(rv["text"], '<p>Здравствуй, мир!</p>')
|
||||
|
||||
def testCreateMultiple(self):
|
||||
|
||||
a = self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
b = self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
c = self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
|
||||
self.assertEqual(loads(a.data)["id"], 1)
|
||||
self.assertEqual(loads(b.data)["id"], 2)
|
||||
self.assertEqual(loads(c.data)["id"], 3)
|
||||
|
||||
def testCreateAndGetMultiple(self):
|
||||
|
||||
for i in range(20):
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Spam'}))
|
||||
|
||||
r = self.get('/?uri=%2Fpath%2F')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 20)
|
||||
|
||||
def testCreateInvalidParent(self):
|
||||
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
|
||||
invalid = self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 2}))
|
||||
|
||||
self.assertEqual(loads(invalid.data)["parent"], 1)
|
||||
|
||||
def testVerifyFields(self):
|
||||
|
||||
verify = lambda comment: comments.API.verify(comment)[0]
|
||||
|
||||
# text is missing
|
||||
self.assertFalse(verify({}))
|
||||
|
||||
# invalid types
|
||||
self.assertFalse(verify({"text": "...", "parent": "xxx"}))
|
||||
for key in ("author", "website", "email"):
|
||||
self.assertFalse(verify({"text": True, key: 3.14}))
|
||||
|
||||
# text too short and/or blank
|
||||
for text in ("", "\n\n\n"):
|
||||
self.assertFalse(verify({"text": text}))
|
||||
|
||||
# email/website length
|
||||
self.assertTrue(verify({"text": "...", "email": "*"*254}))
|
||||
self.assertTrue(verify({"text": "...", "website": "google.de/" + "a"*128}))
|
||||
|
||||
self.assertFalse(verify({"text": "...", "email": "*"*1024}))
|
||||
self.assertFalse(verify({"text": "...", "website": "google.de/" + "*"*1024}))
|
||||
|
||||
# valid website url
|
||||
self.assertTrue(comments.isurl("example.tld"))
|
||||
self.assertTrue(comments.isurl("http://example.tld"))
|
||||
self.assertTrue(comments.isurl("https://example.tld"))
|
||||
self.assertTrue(comments.isurl("https://example.tld:1337/"))
|
||||
self.assertTrue(comments.isurl("https://example.tld:1337/foobar"))
|
||||
self.assertTrue(comments.isurl("https://example.tld:1337/foobar?p=1#isso-thread"))
|
||||
|
||||
self.assertFalse(comments.isurl("ftp://example.tld/"))
|
||||
self.assertFalse(comments.isurl("tel:+1234567890"))
|
||||
self.assertFalse(comments.isurl("+1234567890"))
|
||||
self.assertFalse(comments.isurl("spam"))
|
||||
|
||||
def testGetInvalid(self):
|
||||
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=123').status_code, 404)
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2Fspam%2F&id=123').status_code, 404)
|
||||
self.assertEqual(self.get('/?uri=?uri=%foo%2F').status_code, 404)
|
||||
|
||||
def testGetLimited(self):
|
||||
|
||||
for i in range(20):
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
|
||||
r = self.get('/?uri=test&limit=10')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 10)
|
||||
|
||||
def testGetNested(self):
|
||||
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
|
||||
|
||||
r = self.get('/?uri=test&parent=1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 1)
|
||||
|
||||
def testGetLimitedNested(self):
|
||||
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
for i in range(20):
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
|
||||
|
||||
r = self.get('/?uri=test&parent=1&limit=10')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 10)
|
||||
|
||||
def testUpdate(self):
|
||||
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
self.put('/id/1', data=json.dumps({
|
||||
'text': 'Hello World', 'author': 'me', 'website': 'http://example.com/'}))
|
||||
|
||||
r = self.get('/id/1?plain=1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(rv['text'], 'Hello World')
|
||||
self.assertEqual(rv['author'], 'me')
|
||||
self.assertEqual(rv['website'], 'http://example.com/')
|
||||
self.assertIn('modified', rv)
|
||||
|
||||
def testDelete(self):
|
||||
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
r = self.delete('/id/1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(loads(r.data), None)
|
||||
self.assertEqual(self.get('/id/1').status_code, 404)
|
||||
|
||||
def testDeleteWithReference(self):
|
||||
|
||||
client = JSONClient(self.app, Response)
|
||||
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
|
||||
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First', 'parent': 1}))
|
||||
|
||||
r = client.delete('/id/1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(loads(r.data)['mode'], 4)
|
||||
self.assertIn('/path/', self.app.db.threads)
|
||||
|
||||
data = loads(client.get("/?uri=%2Fpath%2F").data)
|
||||
self.assertEqual(data["total_replies"], 1)
|
||||
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=1').status_code, 200)
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F&id=2').status_code, 200)
|
||||
|
||||
r = client.delete('/id/2')
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 404)
|
||||
self.assertNotIn('/path/', self.app.db.threads)
|
||||
|
||||
def testDeleteWithMultipleReferences(self):
|
||||
"""
|
||||
[ comment 1 ]
|
||||
|
|
||||
--- [ comment 2, ref 1 ]
|
||||
|
|
||||
--- [ comment 3, ref 1 ]
|
||||
[ comment 4 ]
|
||||
"""
|
||||
client = JSONClient(self.app, Response)
|
||||
|
||||
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
|
||||
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Second', 'parent': 1}))
|
||||
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Third', 'parent': 1}))
|
||||
client.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Last'}))
|
||||
|
||||
client.delete('/id/1')
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 200)
|
||||
client.delete('/id/2')
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 200)
|
||||
client.delete('/id/3')
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 200)
|
||||
client.delete('/id/4')
|
||||
self.assertEqual(self.get('/?uri=%2Fpath%2F').status_code, 404)
|
||||
|
||||
def testPathVariations(self):
|
||||
|
||||
paths = ['/sub/path/', '/path.html', '/sub/path.html', 'path', '/']
|
||||
|
||||
for path in paths:
|
||||
self.assertEqual(self.post('/new?' + urlencode({'uri': path}),
|
||||
data=json.dumps({'text': '...'})).status_code, 201)
|
||||
|
||||
for i, path in enumerate(paths):
|
||||
self.assertEqual(self.get('/?' + urlencode({'uri': path})).status_code, 200)
|
||||
self.assertEqual(self.get('/id/%i' % (i + 1)).status_code, 200)
|
||||
|
||||
def testDeleteAndCreateByDifferentUsersButSamePostId(self):
|
||||
|
||||
mallory = JSONClient(self.app, Response)
|
||||
mallory.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Foo'}))
|
||||
mallory.delete('/id/1')
|
||||
|
||||
bob = JSONClient(self.app, Response)
|
||||
bob.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Bar'}))
|
||||
|
||||
self.assertEqual(mallory.delete('/id/1').status_code, 403)
|
||||
self.assertEqual(bob.delete('/id/1').status_code, 200)
|
||||
|
||||
def testHash(self):
|
||||
|
||||
a = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Aaa"}))
|
||||
b = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Bbb"}))
|
||||
c = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Ccc", "email": "..."}))
|
||||
|
||||
a = loads(a.data)
|
||||
b = loads(b.data)
|
||||
c = loads(c.data)
|
||||
|
||||
self.assertNotEqual(a['hash'], '192.168.1.1')
|
||||
self.assertEqual(a['hash'], b['hash'])
|
||||
self.assertNotEqual(a['hash'], c['hash'])
|
||||
|
||||
def testVisibleFields(self):
|
||||
|
||||
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "...", "invalid": "field"}))
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
|
||||
rv = loads(rv.data)
|
||||
|
||||
for key in comments.API.FIELDS:
|
||||
rv.pop(key)
|
||||
|
||||
self.assertListEqual(list(rv.keys()), [])
|
||||
|
||||
def testCounts(self):
|
||||
|
||||
self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404)
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "..."}))
|
||||
|
||||
rv = self.get('/count?uri=%2Fpath%2F')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data), 1)
|
||||
|
||||
for x in range(3):
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "..."}))
|
||||
|
||||
rv = self.get('/count?uri=%2Fpath%2F')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data), 4)
|
||||
|
||||
for x in range(4):
|
||||
self.delete('/id/%i' % (x + 1))
|
||||
|
||||
rv = self.get('/count?uri=%2Fpath%2F')
|
||||
self.assertEqual(rv.status_code, 404)
|
||||
|
||||
def testMultipleCounts(self):
|
||||
|
||||
expected = {'a': 1, 'b': 2, 'c': 0}
|
||||
|
||||
for uri, count in iteritems(expected):
|
||||
for _ in range(count):
|
||||
self.post('/new?uri=%s' % uri, data=json.dumps({"text": "..."}))
|
||||
|
||||
rv = self.post('/count', data=json.dumps(list(expected.keys())))
|
||||
self.assertEqual(loads(rv.data), list(expected.values()))
|
||||
|
||||
def testModify(self):
|
||||
self.post('/new?uri=test', data=json.dumps({"text": "Tpyo"}))
|
||||
|
||||
self.put('/id/1', data=json.dumps({"text": "Tyop"}))
|
||||
self.assertEqual(loads(self.get('/id/1').data)["text"], "<p>Tyop</p>")
|
||||
|
||||
self.put('/id/1', data=json.dumps({"text": "Typo"}))
|
||||
self.assertEqual(loads(self.get('/id/1').data)["text"], "<p>Typo</p>")
|
||||
|
||||
def testDeleteCommentRemovesThread(self):
|
||||
|
||||
self.client.post('/new?uri=%2F', data=json.dumps({"text": "..."}))
|
||||
self.assertIn('/', self.app.db.threads)
|
||||
self.client.delete('/id/1')
|
||||
self.assertNotIn('/', self.app.db.threads)
|
||||
|
||||
def testCSRF(self):
|
||||
|
||||
js = "application/json"
|
||||
form = "application/x-www-form-urlencoded"
|
||||
|
||||
self.post('/new?uri=%2F', data=json.dumps({"text": "..."}))
|
||||
|
||||
# no header is fine (default for XHR)
|
||||
self.assertEqual(self.post('/id/1/dislike', content_type="").status_code, 200)
|
||||
|
||||
# x-www-form-urlencoded is definitely not RESTful
|
||||
self.assertEqual(self.post('/id/1/dislike', content_type=form).status_code, 403)
|
||||
self.assertEqual(self.post('/new?uri=%2F', data=json.dumps({"text": "..."}),
|
||||
content_type=form).status_code, 403)
|
||||
# just for the record
|
||||
self.assertEqual(self.post('/id/1/dislike', content_type=js).status_code, 200)
|
||||
|
||||
|
||||
class TestModeratedComments(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
fd, self.path = tempfile.mkstemp()
|
||||
conf = config.load(os.path.join(dist.location, "share", "isso.conf"))
|
||||
conf.set("general", "dbpath", self.path)
|
||||
conf.set("moderation", "enabled", "true")
|
||||
conf.set("guard", "enabled", "off")
|
||||
conf.set("hash", "algorithm", "none")
|
||||
|
||||
class App(Isso, core.Mixin):
|
||||
pass
|
||||
|
||||
self.app = App(conf)
|
||||
self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
|
||||
self.client = JSONClient(self.app, Response)
|
||||
|
||||
def tearDown(self):
|
||||
os.unlink(self.path)
|
||||
|
||||
def testAddComment(self):
|
||||
|
||||
rv = self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
|
||||
self.assertEqual(rv.status_code, 202)
|
||||
|
||||
self.assertEqual(self.client.get('/id/1').status_code, 200)
|
||||
self.assertEqual(self.client.get('/?uri=test').status_code, 404)
|
||||
|
||||
self.app.db.comments.activate(1)
|
||||
self.assertEqual(self.client.get('/?uri=test').status_code, 200)
|
||||
|
||||
|
||||
class TestPurgeComments(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
fd, self.path = tempfile.mkstemp()
|
||||
conf = config.load(os.path.join(dist.location, "share", "isso.conf"))
|
||||
conf.set("general", "dbpath", self.path)
|
||||
conf.set("moderation", "enabled", "true")
|
||||
conf.set("guard", "enabled", "off")
|
||||
conf.set("hash", "algorithm", "none")
|
||||
|
||||
class App(Isso, core.Mixin):
|
||||
pass
|
||||
|
||||
self.app = App(conf)
|
||||
self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
|
||||
self.client = JSONClient(self.app, Response)
|
||||
|
||||
def testPurgeDoesNoHarm(self):
|
||||
self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
|
||||
self.app.db.comments.activate(1)
|
||||
self.app.db.comments.purge(0)
|
||||
self.assertEqual(self.client.get('/?uri=test').status_code, 200)
|
||||
|
||||
def testPurgeWorks(self):
|
||||
self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
|
||||
self.app.db.comments.purge(0)
|
||||
self.assertEqual(self.client.get('/id/1').status_code, 404)
|
||||
|
||||
self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
|
||||
self.app.db.comments.purge(3600)
|
||||
self.assertEqual(self.client.get('/id/1').status_code, 200)
|
@ -1,115 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
try:
|
||||
import unittest2 as unittest
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from werkzeug import __version__
|
||||
from werkzeug.test import Client
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from isso import Isso, config, core, dist
|
||||
from isso.utils import http
|
||||
|
||||
from fixtures import curl, FakeIP
|
||||
http.curl = curl
|
||||
|
||||
if __version__.startswith("0.8"):
|
||||
class Response(Response):
|
||||
|
||||
def get_data(self, as_text=False):
|
||||
return self.data.decode("utf-8")
|
||||
|
||||
|
||||
class TestGuard(unittest.TestCase):
|
||||
|
||||
data = json.dumps({"text": "Lorem ipsum."})
|
||||
|
||||
def setUp(self):
|
||||
self.path = tempfile.NamedTemporaryFile().name
|
||||
|
||||
def makeClient(self, ip, ratelimit=2, direct_reply=3, self_reply=False):
|
||||
|
||||
conf = config.load(os.path.join(dist.location, "share", "isso.conf"))
|
||||
conf.set("general", "dbpath", self.path)
|
||||
conf.set("hash", "algorithm", "none")
|
||||
conf.set("guard", "enabled", "true")
|
||||
conf.set("guard", "ratelimit", str(ratelimit))
|
||||
conf.set("guard", "direct-reply", str(direct_reply))
|
||||
conf.set("guard", "reply-to-self", "1" if self_reply else "0")
|
||||
|
||||
class App(Isso, core.Mixin):
|
||||
pass
|
||||
|
||||
app = App(conf)
|
||||
app.wsgi_app = FakeIP(app.wsgi_app, ip)
|
||||
|
||||
return Client(app, Response)
|
||||
|
||||
def testRateLimit(self):
|
||||
|
||||
bob = self.makeClient("127.0.0.1", 2)
|
||||
|
||||
for i in range(2):
|
||||
rv = bob.post('/new?uri=test', data=self.data)
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
|
||||
rv = bob.post('/new?uri=test', data=self.data)
|
||||
|
||||
self.assertEqual(rv.status_code, 403)
|
||||
self.assertIn("ratelimit exceeded", rv.get_data(as_text=True))
|
||||
|
||||
alice = self.makeClient("1.2.3.4", 2)
|
||||
for i in range(2):
|
||||
self.assertEqual(alice.post("/new?uri=test", data=self.data).status_code, 201)
|
||||
|
||||
bob.application.db.execute([
|
||||
"UPDATE comments SET",
|
||||
" created = created - 60",
|
||||
"WHERE remote_addr = '127.0.0.0'"
|
||||
])
|
||||
|
||||
self.assertEqual(bob.post("/new?uri=test", data=self.data).status_code, 201)
|
||||
|
||||
def testDirectReply(self):
|
||||
|
||||
client = self.makeClient("127.0.0.1", 15, 3)
|
||||
|
||||
for url in ("foo", "bar", "baz", "spam"):
|
||||
for _ in range(3):
|
||||
rv = client.post("/new?uri=%s" % url, data=self.data)
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
|
||||
for url in ("foo", "bar", "baz", "spam"):
|
||||
rv = client.post("/new?uri=%s" % url, data=self.data)
|
||||
|
||||
self.assertEqual(rv.status_code, 403)
|
||||
self.assertIn("direct responses to", rv.get_data(as_text=True))
|
||||
|
||||
def testSelfReply(self):
|
||||
|
||||
payload = lambda id: json.dumps({"text": "...", "parent": id})
|
||||
|
||||
client = self.makeClient("127.0.0.1", self_reply=False)
|
||||
self.assertEqual(client.post("/new?uri=test", data=self.data).status_code, 201)
|
||||
self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 403)
|
||||
|
||||
client.application.db.execute([
|
||||
"UPDATE comments SET",
|
||||
" created = created - ?",
|
||||
"WHERE id = 1"
|
||||
], (client.application.conf.getint("general", "max-age"), ))
|
||||
|
||||
self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 201)
|
||||
|
||||
client = self.makeClient("128.0.0.1", ratelimit=3, self_reply=False)
|
||||
self.assertEqual(client.post("/new?uri=test", data=self.data).status_code, 201)
|
||||
self.assertEqual(client.post("/new?uri=test", data=payload(1)).status_code, 201)
|
||||
self.assertEqual(client.post("/new?uri=test", data=payload(2)).status_code, 201)
|
@ -0,0 +1,102 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
import datetime
|
||||
|
||||
from isso.queue import Message, Queue, Full, Empty, Timeout, SAQueue
|
||||
|
||||
|
||||
class TestMessage(unittest.TestCase):
|
||||
|
||||
def test_message(self):
|
||||
a = Message("Foo", None)
|
||||
b = Message("Bar", None)
|
||||
|
||||
self.assertLess(a, b)
|
||||
|
||||
def test_message_delay(self):
|
||||
a = Message("Foo", None, delay=1)
|
||||
b = Message("Bar", None, delay=0)
|
||||
|
||||
self.assertGreater(a, b)
|
||||
|
||||
def test_message_wait(self):
|
||||
a = Message("Foo", None)
|
||||
b = Message("Foo", None)
|
||||
a = Queue.delay(a, 1, delayfunc=lambda i: 0.5)
|
||||
|
||||
self.assertGreater(a, b)
|
||||
|
||||
|
||||
class TestQueue(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cls = Queue
|
||||
|
||||
def test_queue(self):
|
||||
q = self.cls()
|
||||
msgs = [Message("Foo", None) for _ in range(3)]
|
||||
|
||||
for msg in msgs:
|
||||
q.put(msg)
|
||||
|
||||
self.assertEqual(q.size, 3)
|
||||
|
||||
for msg in msgs:
|
||||
self.assertEqual(q.get(), msg)
|
||||
|
||||
self.assertEqual(q.size, 0)
|
||||
|
||||
def test_data_primitives(self):
|
||||
q = self.cls()
|
||||
m = Message("Foo", {"foo": True, "bar": [2, 3]})
|
||||
|
||||
q.put(m)
|
||||
self.assertEqual(q.get(), m)
|
||||
|
||||
def test_queue_full(self):
|
||||
q = self.cls(maxlen=1)
|
||||
q.put(Message("Foo", None))
|
||||
|
||||
self.assertRaises(Full, q.put, Message("Bar", None))
|
||||
|
||||
def test_queue_empty(self):
|
||||
q = self.cls()
|
||||
msg = Message("Foo", None)
|
||||
|
||||
self.assertRaises(Empty, q.get)
|
||||
q.put(msg)
|
||||
q.get()
|
||||
self.assertRaises(Empty, q.get)
|
||||
|
||||
def test_retry(self):
|
||||
q = self.cls()
|
||||
msg = Message("Foo", None)
|
||||
|
||||
q.retry(msg)
|
||||
self.assertRaises(Empty, q.get)
|
||||
self.assertEqual(q.size, 1)
|
||||
|
||||
def test_retry_timeout(self):
|
||||
q = self.cls(timeout=0)
|
||||
msg = Message("Foo", None)
|
||||
|
||||
self.assertRaises(Timeout, q.retry, msg)
|
||||
|
||||
def test_requeue(self):
|
||||
q = self.cls()
|
||||
msg = Message("Foo", None)
|
||||
|
||||
q.put(msg)
|
||||
q.requeue(q.get(), datetime.timedelta(seconds=1))
|
||||
|
||||
self.assertRaises(Empty, q.get)
|
||||
self.assertEqual(q.size, 1)
|
||||
|
||||
|
||||
class TestSQLite3Queue(TestQueue):
|
||||
|
||||
def setUp(self):
|
||||
self.cls = lambda *x, **z: SAQueue("sqlite:///:memory:", *x, **z)
|
@ -0,0 +1,92 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from isso import db, spam
|
||||
from isso.models import Thread
|
||||
from isso.controllers import comments
|
||||
|
||||
bob = "127.0.0.1"
|
||||
alice = "127.0.0.2"
|
||||
|
||||
comment = dict(text="...")
|
||||
|
||||
|
||||
class TestGuard(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.db = db.Adapter("sqlite:///:memory:")
|
||||
|
||||
def test_ratelimit(self):
|
||||
thread = Thread(0, "/")
|
||||
|
||||
guard = spam.Guard(self.db, enabled=True, ratelimit=2)
|
||||
controller = comments.Controller(self.db, guard)
|
||||
|
||||
for _ in range(2):
|
||||
controller.new(bob, thread, comment)
|
||||
|
||||
try:
|
||||
controller.new(bob, thread, comment)
|
||||
except Exception as ex:
|
||||
self.assertIsInstance(ex, comments.Denied)
|
||||
self.assertIn("ratelimit exceeded", ex.args[0])
|
||||
else:
|
||||
self.assertTrue(False)
|
||||
|
||||
# check if Alice can still comment
|
||||
for _ in range(2):
|
||||
controller.new(alice, thread, comment)
|
||||
|
||||
# 60 seconds are gone now
|
||||
self.db.engine.execute(
|
||||
self.db.comments.update().values(
|
||||
created=self.db.comments.c.created - 60
|
||||
).where(self.db.comments.c.remote_addr == bob))
|
||||
|
||||
controller.new(bob, thread, comment)
|
||||
|
||||
def test_direct_reply(self):
|
||||
threads = [Thread(0, "/foo"), Thread(1, "/bar")]
|
||||
|
||||
guard = spam.Guard(self.db, enabled=True, ratelimit=-1, direct_reply=3)
|
||||
controller = comments.Controller(self.db, guard)
|
||||
|
||||
for thread in threads:
|
||||
for _ in range(3):
|
||||
controller.new(bob, thread, comment)
|
||||
|
||||
for thread in threads:
|
||||
try:
|
||||
controller.new(bob, thread, comment)
|
||||
except Exception as ex:
|
||||
self.assertIsInstance(ex, comments.Denied)
|
||||
self.assertIn("direct response", ex.args[0])
|
||||
else:
|
||||
self.assertTrue(False)
|
||||
|
||||
def test_self_reply(self):
|
||||
thread = Thread(0, "/")
|
||||
|
||||
guard = spam.Guard(self.db, enabled=True, reply_to_self=False)
|
||||
controller = comments.Controller(self.db, guard)
|
||||
|
||||
ref = controller.new(bob, thread, comment).id
|
||||
|
||||
try:
|
||||
controller.new(bob, thread, dict(text="...", parent=ref))
|
||||
except Exception as ex:
|
||||
self.assertIsInstance(ex, comments.Denied)
|
||||
self.assertIn("editing frame is still open", ex.args[0])
|
||||
else:
|
||||
self.assertTrue(False)
|
||||
|
||||
# fast-forward `max-age` seconds
|
||||
self.db.engine.execute(
|
||||
self.db.comments.update().values(
|
||||
created=self.db.comments.c.created - guard.max_age
|
||||
).where(self.db.comments.c.id == ref))
|
||||
|
||||
controller.new(bob, thread, dict(text="...", parent=ref))
|
@ -0,0 +1,24 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from isso.compat import string_types
|
||||
from isso.utils import types
|
||||
|
||||
|
||||
class TestTypes(unittest.TestCase):
|
||||
|
||||
def test_require(self):
|
||||
|
||||
try:
|
||||
types.require("foo", string_types)
|
||||
except TypeError:
|
||||
self.assertTrue(False)
|
||||
|
||||
def test_require_raises(self):
|
||||
|
||||
self.assertRaises(TypeError, types.require, 1, bool)
|
||||
self.assertRaises(TypeError, types.require, 1, str)
|
||||
|
@ -1,96 +0,0 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
import unittest2 as unittest
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from isso import Isso, core, config, dist
|
||||
from isso.utils import http
|
||||
|
||||
from fixtures import curl, loads, FakeIP, JSONClient
|
||||
http.curl = curl
|
||||
|
||||
|
||||
class TestVote(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.path = tempfile.NamedTemporaryFile().name
|
||||
|
||||
def makeClient(self, ip):
|
||||
|
||||
conf = config.load(os.path.join(dist.location, "share", "isso.conf"))
|
||||
conf.set("general", "dbpath", self.path)
|
||||
conf.set("guard", "enabled", "off")
|
||||
conf.set("hash", "algorithm", "none")
|
||||
|
||||
class App(Isso, core.Mixin):
|
||||
pass
|
||||
|
||||
app = App(conf)
|
||||
app.wsgi_app = FakeIP(app.wsgi_app, ip)
|
||||
|
||||
return JSONClient(app, Response)
|
||||
|
||||
def testZeroLikes(self):
|
||||
|
||||
rv = self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
|
||||
self.assertEqual(loads(rv.data)['likes'], 0)
|
||||
self.assertEqual(loads(rv.data)['dislikes'], 0)
|
||||
|
||||
def testSingleLike(self):
|
||||
|
||||
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
|
||||
rv = self.makeClient("0.0.0.0").post("/id/1/like")
|
||||
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data)["likes"], 1)
|
||||
|
||||
def testSelfLike(self):
|
||||
|
||||
bob = self.makeClient("127.0.0.1")
|
||||
bob.post("/new?uri=test", data=json.dumps({"text": "..."}))
|
||||
rv = bob.post('/id/1/like')
|
||||
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data)["likes"], 0)
|
||||
|
||||
def testMultipleLikes(self):
|
||||
|
||||
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
|
||||
for num in range(15):
|
||||
rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data)["likes"], num + 1)
|
||||
|
||||
def testVoteOnNonexistentComment(self):
|
||||
rv = self.makeClient("1.2.3.4").post('/id/1/like')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data), None)
|
||||
|
||||
def testTooManyLikes(self):
|
||||
|
||||
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
|
||||
for num in range(256):
|
||||
rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
|
||||
if num >= 142:
|
||||
self.assertEqual(loads(rv.data)["likes"], 142)
|
||||
else:
|
||||
self.assertEqual(loads(rv.data)["likes"], num + 1)
|
||||
|
||||
def testDislike(self):
|
||||
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
|
||||
rv = self.makeClient("1.2.3.4").post('/id/1/dislike')
|
||||
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(loads(rv.data)['likes'], 0)
|
||||
self.assertEqual(loads(rv.data)['dislikes'], 1)
|
@ -0,0 +1,190 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import unittest
|
||||
|
||||
from werkzeug.test import Client, EnvironBuilder
|
||||
from werkzeug.wrappers import Response, Request
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from isso import Isso, config, dist
|
||||
from isso.utils import http
|
||||
from isso.views.api import xhr
|
||||
|
||||
|
||||
class FakeIP(object):
|
||||
|
||||
def __init__(self, app, ip):
|
||||
self.app = app
|
||||
self.ip = ip
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
environ['REMOTE_ADDR'] = self.ip
|
||||
return self.app(environ, start_response)
|
||||
|
||||
|
||||
class JSONClient(Client):
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
kwargs.setdefault('content_type', 'application/json')
|
||||
return super(JSONClient, self).open(*args, **kwargs)
|
||||
|
||||
|
||||
class Dummy(object):
|
||||
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def read(self):
|
||||
return ''
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
curl = lambda method, host, path: Dummy()
|
||||
loads = lambda data: json.loads(data.decode('utf-8'))
|
||||
|
||||
http.curl = curl
|
||||
|
||||
|
||||
class TestComments(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
conf = config.load(os.path.join(dist.location, "isso", "defaults.ini"))
|
||||
conf.set("general", "dbpath", "sqlite:///:memory:")
|
||||
conf.set("general", "max-age", "900")
|
||||
conf.set("guard", "enabled", "off")
|
||||
conf.set("hash", "algorithm", "none")
|
||||
|
||||
self.app = Isso(conf)
|
||||
self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
|
||||
|
||||
self.client = JSONClient(self.app, Response)
|
||||
self.get = self.client.get
|
||||
self.put = self.client.put
|
||||
self.post = self.client.post
|
||||
self.delete = self.client.delete
|
||||
|
||||
# done (except Markup)
|
||||
def testGet(self):
|
||||
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
r = self.get('/id/1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
|
||||
self.assertEqual(rv['id'], 1)
|
||||
self.assertEqual(rv['text'], '<p>Lorem ipsum ...</p>')
|
||||
|
||||
# done (except Set-Cookie)
|
||||
def testCreate(self):
|
||||
|
||||
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
self.assertIn("Set-Cookie", rv.headers)
|
||||
self.assertIn("X-Set-Cookie", rv.headers)
|
||||
|
||||
rv = loads(rv.data)
|
||||
|
||||
self.assertEqual(rv["mode"], 1)
|
||||
self.assertEqual(rv["text"], '<p>Lorem ipsum ...</p>')
|
||||
|
||||
def testGetLimited(self):
|
||||
|
||||
for i in range(20):
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
|
||||
r = self.get('/?uri=test&limit=10')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 10)
|
||||
|
||||
def testGetNested(self):
|
||||
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
|
||||
|
||||
r = self.get('/?uri=test&parent=1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 1)
|
||||
|
||||
def testGetLimitedNested(self):
|
||||
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...'}))
|
||||
for i in range(20):
|
||||
self.post('/new?uri=test', data=json.dumps({'text': '...', 'parent': 1}))
|
||||
|
||||
r = self.get('/?uri=test&parent=1&limit=10')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(len(rv['replies']), 10)
|
||||
|
||||
# done (except plain)
|
||||
def testUpdate(self):
|
||||
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
|
||||
self.put('/id/1', data=json.dumps({
|
||||
'text': 'Hello World', 'author': 'me', 'website': 'http://example.com/'}))
|
||||
|
||||
r = self.get('/id/1?plain=1')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
rv = loads(r.data)
|
||||
self.assertEqual(rv['text'], 'Hello World')
|
||||
self.assertEqual(rv['author'], 'me')
|
||||
self.assertEqual(rv['website'], 'http://example.com/')
|
||||
self.assertIn('modified', rv)
|
||||
|
||||
def testDeleteAndCreateByDifferentUsersButSamePostId(self):
|
||||
|
||||
mallory = JSONClient(self.app, Response)
|
||||
mallory.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Foo'}))
|
||||
mallory.delete('/id/1')
|
||||
|
||||
bob = JSONClient(self.app, Response)
|
||||
bob.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Bar'}))
|
||||
|
||||
self.assertEqual(mallory.delete('/id/1').status_code, 403)
|
||||
self.assertEqual(bob.delete('/id/1').status_code, 200)
|
||||
|
||||
def testCSRF(self):
|
||||
|
||||
csrf = xhr(lambda *x, **z: True)
|
||||
|
||||
def build(**kw):
|
||||
environ = EnvironBuilder(**kw).get_environ()
|
||||
return environ, Request(environ)
|
||||
|
||||
# no header is fine (default for XHR)
|
||||
env, req = build()
|
||||
self.assertTrue(csrf(None, env, req))
|
||||
|
||||
# for the record
|
||||
env, req = build(content_type="application/json")
|
||||
self.assertTrue(csrf(None, env, req))
|
||||
|
||||
# # x-www-form-urlencoded is definitely not RESTful
|
||||
env, req = build(content_type="application/x-www-form-urlencoded")
|
||||
self.assertRaises(Forbidden, csrf, None, env, req)
|
||||
|
||||
def testCookieExpiration(self):
|
||||
|
||||
rv = self.post('/new?uri=%2Fpath%2F', data=json.dumps({"text": "Hello, World!"}))
|
||||
headers = rv.headers
|
||||
|
||||
for key in ("Set-Cookie", "X-Set-Cookie"):
|
||||
self.assertTrue(headers.has_key(key))
|
||||
self.assertIn("max-age=900", headers.get(key).lower())
|
@ -0,0 +1,24 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
|
||||
def _TypeError(expected, val):
|
||||
if isinstance(expected, (list, tuple)):
|
||||
expected = ", ".join(ex.__name__ for ex in expected)
|
||||
else:
|
||||
expected = expected.__name__
|
||||
return TypeError("Expected {0}, not {1}".format(
|
||||
expected, val.__class__.__name__))
|
||||
|
||||
|
||||
def require(val, expected):
|
||||
"""Assure that :param val: is an instance of :param expected: or raise a
|
||||
:exception TypeError: indicating what's expected.
|
||||
|
||||
>>> require(23, int)
|
||||
>>> require(None, bool)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
TypeError: Expected bool, not NoneType
|
||||
"""
|
||||
if not isinstance(val, expected):
|
||||
raise _TypeError(expected, val)
|
@ -0,0 +1,377 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from functools import partial
|
||||
|
||||
from itsdangerous import SignatureExpired, BadSignature
|
||||
|
||||
from werkzeug.http import dump_cookie
|
||||
from werkzeug.wrappers import Response
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from isso.compat import text_type as str, string_types
|
||||
|
||||
from isso import utils
|
||||
from isso.utils import JSONResponse as JSON
|
||||
from isso.views import requires
|
||||
from isso.utils.hash import sha1
|
||||
|
||||
from isso.controllers import threads, comments
|
||||
|
||||
|
||||
def normalize(url):
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return "http://" + url
|
||||
return url
|
||||
|
||||
|
||||
def xhr(func):
|
||||
"""A decorator to check for CSRF on POST/PUT/DELETE using a <form>
|
||||
element and JS to execute automatically (see #40 for a proof-of-concept).
|
||||
|
||||
When an attacker uses a <form> to downvote a comment, the browser *should*
|
||||
add a `Content-Type: ...` header with three possible values:
|
||||
|
||||
* application/x-www-form-urlencoded
|
||||
* multipart/form-data
|
||||
* text/plain
|
||||
|
||||
If the header is not sent or requests `application/json`, the request is
|
||||
not forged (XHR is restricted by CORS separately).
|
||||
"""
|
||||
|
||||
def dec(self, env, req, *args, **kwargs):
|
||||
|
||||
if req.content_type and not req.content_type.startswith("application/json"):
|
||||
raise Forbidden("CSRF")
|
||||
return func(self, env, req, *args, **kwargs)
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
class API(object):
|
||||
|
||||
# comment fields, that can be submitted
|
||||
ACCEPT = set(['text', 'author', 'email', 'website', 'parent'])
|
||||
|
||||
def __init__(self, conf, cache, db, guard, hash, markup, signer):
|
||||
self.conf = conf
|
||||
|
||||
self.db = db
|
||||
self.cache = cache
|
||||
|
||||
self.hash = hash
|
||||
self.markup = markup
|
||||
|
||||
self.threads = threads.Controller(db)
|
||||
self.comments = comments.Controller(db, guard)
|
||||
|
||||
self.max_age = conf.getint("general", "max-age")
|
||||
self.moderated = conf.getboolean("moderation", "enabled")
|
||||
|
||||
self.sign = signer.dumps
|
||||
self.load = partial(signer.loads, max_age=self.max_age)
|
||||
|
||||
def serialize(self, comment, markup=True):
|
||||
_id = str(comment.id)
|
||||
obj = {
|
||||
"id": comment.id, "parent": comment.parent,
|
||||
"mode": comment.mode,
|
||||
"created": comment.created, "modified": comment.modified,
|
||||
"text": comment.text, "author": comment.author,
|
||||
"email": comment.email, "website": comment.website,
|
||||
"likes": comment.likes, "dislikes": comment.dislikes}
|
||||
|
||||
if markup:
|
||||
html = self.cache.get("text", _id)
|
||||
if html is None:
|
||||
html = self.markup.render(comment.text)
|
||||
self.cache.set("text", _id, html)
|
||||
obj["text"] = html
|
||||
|
||||
hash = self.cache.get("hash", _id)
|
||||
if hash is None:
|
||||
hash = self.hash(comment.email or comment.remote_addr)
|
||||
self.cache.set("hash", _id, hash)
|
||||
obj["hash"] = hash
|
||||
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def sanitize(cls, data):
|
||||
if not isinstance(data, dict):
|
||||
raise BadRequest(400, "request data is not an object")
|
||||
|
||||
for field in set(data.keys()) - API.ACCEPT:
|
||||
data.pop(field)
|
||||
|
||||
if isinstance(data.get("website", None), string_types):
|
||||
data["website"] = normalize(data["website"])
|
||||
|
||||
return data
|
||||
|
||||
@xhr
|
||||
@requires(str, 'uri')
|
||||
def new(self, environ, request, uri):
|
||||
data = API.sanitize(request.get_json())
|
||||
|
||||
remote_addr = utils.anonymize(str(request.remote_addr))
|
||||
|
||||
with self.db.transaction:
|
||||
thread = self.threads.get(uri)
|
||||
if thread is None:
|
||||
thread = self.threads.new(uri)
|
||||
try:
|
||||
comment = self.comments.new(remote_addr, thread, data,
|
||||
moderated=self.moderated)
|
||||
except comments.Invalid as ex:
|
||||
raise BadRequest(ex.message)
|
||||
except comments.Denied as ex:
|
||||
raise Forbidden(ex.message)
|
||||
|
||||
# TODO queue new thread, send notification
|
||||
|
||||
cookie = partial(dump_cookie, max_age=self.max_age)
|
||||
signature = self.sign([comment.id, sha1(comment.text)])
|
||||
|
||||
resp = JSON(
|
||||
self.serialize(comment),
|
||||
202 if comment.moderated == 2 else 201)
|
||||
resp.headers.add("Set-Cookie", cookie(str(comment.id), signature))
|
||||
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % comment.id, signature))
|
||||
return resp
|
||||
|
||||
def view(self, environ, request, id):
|
||||
comment = self.comments.get(id)
|
||||
|
||||
if comment is None:
|
||||
raise NotFound
|
||||
|
||||
markup = request.args.get('plain', '0') == '0'
|
||||
|
||||
return JSON(self.serialize(comment, markup), 200)
|
||||
|
||||
@xhr
|
||||
def edit(self, environ, request, id):
|
||||
|
||||
try:
|
||||
rv = self.load(request.cookies.get(str(id), ""))
|
||||
except (SignatureExpired, BadSignature):
|
||||
raise Forbidden
|
||||
|
||||
if rv[0] != id:
|
||||
raise Forbidden
|
||||
|
||||
comment = self.comments.get(id)
|
||||
if comment is None:
|
||||
raise NotFound
|
||||
|
||||
# verify checksum, mallory might skip cookie deletion when
|
||||
# he deletes a comment
|
||||
if rv[1] != sha1(comment.text):
|
||||
raise Forbidden
|
||||
|
||||
data = API.sanitize(request.get_json())
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise BadRequest(400, "request data is not an object")
|
||||
|
||||
for field in set(data.keys()) - API.ACCEPT:
|
||||
data.pop(field)
|
||||
|
||||
with self.db.transaction:
|
||||
comment = self.comments.edit(id, data)
|
||||
|
||||
_id = str(comment.id)
|
||||
|
||||
cookie = partial(dump_cookie, max_age=self.max_age)
|
||||
signature = self.sign([comment.id, sha1(comment.text)])
|
||||
|
||||
self.cache.delete("text", _id)
|
||||
self.cache.delete("hash", _id)
|
||||
|
||||
resp = JSON(self.serialize(comment), 200)
|
||||
resp.headers.add("Set-Cookie", cookie(_id, signature))
|
||||
resp.headers.add("X-Set-Cookie", cookie("isso-" + _id, signature))
|
||||
return resp
|
||||
|
||||
@xhr
|
||||
def delete(self, environ, request, id, key=None):
|
||||
|
||||
try:
|
||||
rv = self.load(request.cookies.get(str(id), ""))
|
||||
except (SignatureExpired, BadSignature):
|
||||
raise Forbidden
|
||||
|
||||
if rv[0] != id:
|
||||
raise Forbidden
|
||||
|
||||
comment = self.comments.get(id)
|
||||
if comment is None:
|
||||
raise NotFound
|
||||
|
||||
if rv[1] != sha1(comment.text):
|
||||
raise Forbidden
|
||||
|
||||
_id = str(comment.id)
|
||||
|
||||
self.cache.delete("text", _id)
|
||||
self.cache.delete("hash", _id)
|
||||
|
||||
with self.db.transaction:
|
||||
comment = self.comments.delete(id)
|
||||
|
||||
cookie = partial(dump_cookie, expires=0, max_age=0)
|
||||
|
||||
resp = JSON(self.serialize(comment) if comment else None, 200)
|
||||
resp.headers.add("Set-Cookie", cookie(_id))
|
||||
resp.headers.add("X-Set-Cookie", cookie("isso-" + _id))
|
||||
return resp
|
||||
|
||||
def moderate(self, environ, request, id, action, key):
|
||||
|
||||
try:
|
||||
id = self.load(key, max_age=2**32)
|
||||
except (BadSignature, SignatureExpired):
|
||||
raise Forbidden
|
||||
|
||||
comment = self.comments.get(id)
|
||||
if comment is None:
|
||||
raise NotFound
|
||||
|
||||
if request.method == "GET":
|
||||
modal = (
|
||||
"<!DOCTYPE html>"
|
||||
"<html>"
|
||||
"<head>"
|
||||
"<script>"
|
||||
" if (confirm('{0}: Are you sure?')) {"
|
||||
" xhr = new XMLHttpRequest;"
|
||||
" xhr.open('POST', window.location.href);"
|
||||
" xhr.send(null);"
|
||||
" }"
|
||||
"</script>".format(action.capitalize()))
|
||||
|
||||
return Response(modal, 200, content_type="text/html")
|
||||
|
||||
if action == "activate":
|
||||
with self.db.transaction:
|
||||
self.comments.activate(id)
|
||||
else:
|
||||
with self.db.transaction:
|
||||
self.comments.delete(id)
|
||||
|
||||
self.cache.delete("text", str(comment.id))
|
||||
self.cache.delete("hash", str(comment.id))
|
||||
|
||||
return Response("Ok", 200)
|
||||
|
||||
# FIXME move logic into controller
|
||||
@requires(str, 'uri')
|
||||
def fetch(self, environ, request, uri):
|
||||
|
||||
thread = self.threads.get(uri)
|
||||
if thread is None:
|
||||
raise NotFound
|
||||
|
||||
args = {
|
||||
'thread': thread,
|
||||
'after': request.args.get('after', 0)
|
||||
}
|
||||
|
||||
try:
|
||||
args['limit'] = int(request.args.get('limit'))
|
||||
except TypeError:
|
||||
args['limit'] = None
|
||||
except ValueError:
|
||||
return BadRequest("limit should be integer")
|
||||
|
||||
if request.args.get('parent') is not None:
|
||||
try:
|
||||
args['parent'] = int(request.args.get('parent'))
|
||||
root_id = args['parent']
|
||||
except ValueError:
|
||||
return BadRequest("parent should be integer")
|
||||
else:
|
||||
args['parent'] = None
|
||||
root_id = None
|
||||
|
||||
reply_counts = self.comments.reply_count(thread, after=args['after'])
|
||||
|
||||
if args['limit'] == 0:
|
||||
root_list = []
|
||||
else:
|
||||
root_list = list(self.comments.all(**args))
|
||||
if not root_list:
|
||||
raise NotFound
|
||||
|
||||
if root_id not in reply_counts:
|
||||
reply_counts[root_id] = 0
|
||||
|
||||
try:
|
||||
nested_limit = int(request.args.get('nested_limit'))
|
||||
except TypeError:
|
||||
nested_limit = None
|
||||
except ValueError:
|
||||
return BadRequest("nested_limit should be integer")
|
||||
|
||||
rv = {
|
||||
'id' : root_id,
|
||||
'total_replies' : reply_counts[root_id],
|
||||
'hidden_replies' : reply_counts[root_id] - len(root_list),
|
||||
'replies' : self._process_fetched_list(root_list)
|
||||
}
|
||||
# We are only checking for one level deep comments
|
||||
if root_id is None:
|
||||
for comment in rv['replies']:
|
||||
if comment['id'] in reply_counts:
|
||||
comment['total_replies'] = reply_counts[comment['id']]
|
||||
if nested_limit is not None:
|
||||
if nested_limit > 0:
|
||||
args['parent'] = comment['id']
|
||||
args['limit'] = nested_limit
|
||||
replies = list(self.comments.all(**args))
|
||||
else:
|
||||
replies = []
|
||||
else:
|
||||
args['parent'] = comment['id']
|
||||
replies = list(self.comments.all(**args))
|
||||
else:
|
||||
comment['total_replies'] = 0
|
||||
replies = []
|
||||
|
||||
comment['hidden_replies'] = comment['total_replies'] - len(replies)
|
||||
comment['replies'] = self._process_fetched_list(replies)
|
||||
|
||||
return JSON(rv, 200)
|
||||
|
||||
def _process_fetched_list(self, fetched_list):
|
||||
return map(self.serialize, fetched_list)
|
||||
|
||||
@xhr
|
||||
def like(self, environ, request, id):
|
||||
remote_addr = utils.anonymize(str(request.remote_addr))
|
||||
|
||||
if not self.comments.like(remote_addr, id):
|
||||
raise BadRequest
|
||||
|
||||
return Response("Ok", 200)
|
||||
|
||||
@xhr
|
||||
def dislike(self, environ, request, id):
|
||||
remote_addr = utils.anonymize(str(request.remote_addr))
|
||||
|
||||
if not self.comments.dislike(remote_addr, id):
|
||||
raise BadRequest
|
||||
|
||||
return Response("Ok", 200)
|
||||
|
||||
def count(self, environ, request):
|
||||
data = request.get_json()
|
||||
|
||||
if not isinstance(data, list) and not all(isinstance(x, str) for x in data):
|
||||
raise BadRequest("JSON must be a list of URLs")
|
||||
|
||||
th = [self.threads.get(uri) for uri in data]
|
||||
return JSON(self.comments.count(*th), 200)
|
@ -1,480 +0,0 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import cgi
|
||||
import time
|
||||
import functools
|
||||
|
||||
from itsdangerous import SignatureExpired, BadSignature
|
||||
|
||||
from werkzeug.http import dump_cookie
|
||||
from werkzeug.wsgi import get_current_url
|
||||
from werkzeug.utils import redirect
|
||||
from werkzeug.routing import Rule
|
||||
from werkzeug.wrappers import Response
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from isso.compat import text_type as str
|
||||
|
||||
from isso import utils, local
|
||||
from isso.utils import http, parse, JSONResponse as JSON
|
||||
from isso.views import requires
|
||||
from isso.utils.hash import sha1
|
||||
|
||||
# from Django appearently, looks good to me *duck*
|
||||
__url_re = re.compile(
|
||||
r'^'
|
||||
r'(https?://)?'
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
|
||||
r'localhost|' # localhost...
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||
r'(?::\d+)?' # optional port
|
||||
r'(?:/?|[/?]\S+)'
|
||||
r'$', re.IGNORECASE)
|
||||
|
||||
|
||||
def isurl(text):
|
||||
return __url_re.match(text) is not None
|
||||
|
||||
|
||||
def normalize(url):
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return "http://" + url
|
||||
return url
|
||||
|
||||
|
||||
def xhr(func):
|
||||
"""A decorator to check for CSRF on POST/PUT/DELETE using a <form>
|
||||
element and JS to execute automatically (see #40 for a proof-of-concept).
|
||||
|
||||
When an attacker uses a <form> to downvote a comment, the browser *should*
|
||||
add a `Content-Type: ...` header with three possible values:
|
||||
|
||||
* application/x-www-form-urlencoded
|
||||
* multipart/form-data
|
||||
* text/plain
|
||||
|
||||
If the header is not sent or requests `application/json`, the request is
|
||||
not forged (XHR is restricted by CORS separately).
|
||||
"""
|
||||
|
||||
def dec(self, env, req, *args, **kwargs):
|
||||
|
||||
if req.content_type and not req.content_type.startswith("application/json"):
|
||||
raise Forbidden("CSRF")
|
||||
return func(self, env, req, *args, **kwargs)
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
class API(object):
|
||||
|
||||
FIELDS = set(['id', 'parent', 'text', 'author', 'website', 'email',
|
||||
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash'])
|
||||
|
||||
# comment fields, that can be submitted
|
||||
ACCEPT = set(['text', 'author', 'website', 'email', 'parent'])
|
||||
|
||||
VIEWS = [
|
||||
('fetch', ('GET', '/')),
|
||||
('new', ('POST', '/new')),
|
||||
('count', ('GET', '/count')),
|
||||
('counts', ('POST', '/count')),
|
||||
('view', ('GET', '/id/<int:id>')),
|
||||
('edit', ('PUT', '/id/<int:id>')),
|
||||
('delete', ('DELETE', '/id/<int:id>')),
|
||||
('moderate',('GET', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
|
||||
('moderate',('POST', '/id/<int:id>/<any(activate,delete):action>/<string:key>')),
|
||||
('like', ('POST', '/id/<int:id>/like')),
|
||||
('dislike', ('POST', '/id/<int:id>/dislike')),
|
||||
('demo', ('GET', '/demo'))
|
||||
]
|
||||
|
||||
def __init__(self, isso, hasher):
|
||||
|
||||
self.isso = isso
|
||||
self.hash = hasher.uhash
|
||||
self.cache = isso.cache
|
||||
self.signal = isso.signal
|
||||
|
||||
self.conf = isso.conf.section("general")
|
||||
self.moderated = isso.conf.getboolean("moderation", "enabled")
|
||||
|
||||
self.guard = isso.db.guard
|
||||
self.threads = isso.db.threads
|
||||
self.comments = isso.db.comments
|
||||
|
||||
for (view, (method, path)) in self.VIEWS:
|
||||
isso.urls.add(
|
||||
Rule(path, methods=[method], endpoint=getattr(self, view)))
|
||||
|
||||
@classmethod
|
||||
def verify(cls, comment):
|
||||
|
||||
if "text" not in comment:
|
||||
return False, "text is missing"
|
||||
|
||||
if not isinstance(comment.get("parent"), (int, type(None))):
|
||||
return False, "parent must be an integer or null"
|
||||
|
||||
for key in ("text", "author", "website", "email"):
|
||||
if not isinstance(comment.get(key), (str, type(None))):
|
||||
return False, "%s must be a string or null" % key
|
||||
|
||||
if len(comment["text"].rstrip()) < 3:
|
||||
return False, "text is too short (minimum length: 3)"
|
||||
|
||||
if len(comment["text"]) > 65535:
|
||||
return False, "text is too long (maximum length: 65535)"
|
||||
|
||||
if len(comment.get("email") or "") > 254:
|
||||
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
|
||||
|
||||
if comment.get("website"):
|
||||
if len(comment["website"]) > 254:
|
||||
return False, "arbitrary length limit"
|
||||
if not isurl(comment["website"]):
|
||||
return False, "Website not Django-conform"
|
||||
|
||||
return True, ""
|
||||
|
||||
@xhr
|
||||
@requires(str, 'uri')
|
||||
def new(self, environ, request, uri):
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
for field in set(data.keys()) - API.ACCEPT:
|
||||
data.pop(field)
|
||||
|
||||
for key in ("author", "email", "website", "parent"):
|
||||
data.setdefault(key, None)
|
||||
|
||||
valid, reason = API.verify(data)
|
||||
if not valid:
|
||||
return BadRequest(reason)
|
||||
|
||||
for field in ("author", "email", "website"):
|
||||
if data.get(field) is not None:
|
||||
data[field] = cgi.escape(data[field])
|
||||
|
||||
if data.get("website"):
|
||||
data["website"] = normalize(data["website"])
|
||||
|
||||
data['mode'] = 2 if self.moderated else 1
|
||||
data['remote_addr'] = utils.anonymize(str(request.remote_addr))
|
||||
|
||||
with self.isso.lock:
|
||||
if uri not in self.threads:
|
||||
with http.curl('GET', local("origin"), uri) as resp:
|
||||
if resp and resp.status == 200:
|
||||
uri, title = parse.thread(resp.read(), id=uri)
|
||||
else:
|
||||
return NotFound('URI does not exist')
|
||||
|
||||
thread = self.threads.new(uri, title)
|
||||
self.signal("comments.new:new-thread", thread)
|
||||
else:
|
||||
thread = self.threads[uri]
|
||||
|
||||
# notify extensions that the new comment is about to save
|
||||
self.signal("comments.new:before-save", thread, data)
|
||||
|
||||
valid, reason = self.guard.validate(uri, data)
|
||||
if not valid:
|
||||
self.signal("comments.new:guard", reason)
|
||||
raise Forbidden(reason)
|
||||
|
||||
with self.isso.lock:
|
||||
rv = self.comments.add(uri, data)
|
||||
|
||||
# notify extension, that the new comment has been successfully saved
|
||||
self.signal("comments.new:after-save", thread, rv)
|
||||
|
||||
cookie = functools.partial(dump_cookie,
|
||||
value=self.isso.sign([rv["id"], sha1(rv["text"])]),
|
||||
max_age=self.conf.getint('max-age'))
|
||||
|
||||
rv["text"] = self.isso.render(rv["text"])
|
||||
rv["hash"] = self.hash(rv['email'] or rv['remote_addr'])
|
||||
|
||||
self.cache.set('hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
|
||||
|
||||
for key in set(rv.keys()) - API.FIELDS:
|
||||
rv.pop(key)
|
||||
|
||||
# success!
|
||||
self.signal("comments.new:finish", thread, rv)
|
||||
|
||||
resp = JSON(rv, 202 if rv["mode"] == 2 else 201)
|
||||
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
|
||||
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
|
||||
return resp
|
||||
|
||||
def view(self, environ, request, id):
|
||||
|
||||
rv = self.comments.get(id)
|
||||
if rv is None:
|
||||
raise NotFound
|
||||
|
||||
for key in set(rv.keys()) - API.FIELDS:
|
||||
rv.pop(key)
|
||||
|
||||
if request.args.get('plain', '0') == '0':
|
||||
rv['text'] = self.isso.render(rv['text'])
|
||||
|
||||
return JSON(rv, 200)
|
||||
|
||||
@xhr
|
||||
def edit(self, environ, request, id):
|
||||
|
||||
try:
|
||||
rv = self.isso.unsign(request.cookies.get(str(id), ''))
|
||||
except (SignatureExpired, BadSignature):
|
||||
raise Forbidden
|
||||
|
||||
if rv[0] != id:
|
||||
raise Forbidden
|
||||
|
||||
# verify checksum, mallory might skip cookie deletion when he deletes a comment
|
||||
if rv[1] != sha1(self.comments.get(id)["text"]):
|
||||
raise Forbidden
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if "text" not in data or data["text"] is None or len(data["text"]) < 3:
|
||||
raise BadRequest("no text given")
|
||||
|
||||
for key in set(data.keys()) - set(["text", "author", "website"]):
|
||||
data.pop(key)
|
||||
|
||||
data['modified'] = time.time()
|
||||
|
||||
with self.isso.lock:
|
||||
rv = self.comments.update(id, data)
|
||||
|
||||
for key in set(rv.keys()) - API.FIELDS:
|
||||
rv.pop(key)
|
||||
|
||||
self.signal("comments.edit", rv)
|
||||
|
||||
cookie = functools.partial(dump_cookie,
|
||||
value=self.isso.sign([rv["id"], sha1(rv["text"])]),
|
||||
max_age=self.conf.getint('max-age'))
|
||||
|
||||
rv["text"] = self.isso.render(rv["text"])
|
||||
|
||||
resp = JSON(rv, 200)
|
||||
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
|
||||
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
|
||||
return resp
|
||||
|
||||
@xhr
|
||||
def delete(self, environ, request, id, key=None):
|
||||
|
||||
try:
|
||||
rv = self.isso.unsign(request.cookies.get(str(id), ""))
|
||||
except (SignatureExpired, BadSignature):
|
||||
raise Forbidden
|
||||
else:
|
||||
if rv[0] != id:
|
||||
raise Forbidden
|
||||
|
||||
# verify checksum, mallory might skip cookie deletion when he deletes a comment
|
||||
if rv[1] != sha1(self.comments.get(id)["text"]):
|
||||
raise Forbidden
|
||||
|
||||
item = self.comments.get(id)
|
||||
|
||||
if item is None:
|
||||
raise NotFound
|
||||
|
||||
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
|
||||
|
||||
with self.isso.lock:
|
||||
rv = self.comments.delete(id)
|
||||
|
||||
if rv:
|
||||
for key in set(rv.keys()) - API.FIELDS:
|
||||
rv.pop(key)
|
||||
|
||||
self.signal("comments.delete", id)
|
||||
|
||||
resp = JSON(rv, 200)
|
||||
cookie = functools.partial(dump_cookie, expires=0, max_age=0)
|
||||
resp.headers.add("Set-Cookie", cookie(str(id)))
|
||||
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
|
||||
return resp
|
||||
|
||||
def moderate(self, environ, request, id, action, key):
|
||||
|
||||
try:
|
||||
id = self.isso.unsign(key, max_age=2**32)
|
||||
except (BadSignature, SignatureExpired):
|
||||
raise Forbidden
|
||||
|
||||
item = self.comments.get(id)
|
||||
|
||||
if item is None:
|
||||
raise NotFound
|
||||
|
||||
if request.method == "GET":
|
||||
modal = (
|
||||
"<!DOCTYPE html>"
|
||||
"<html>"
|
||||
"<head>"
|
||||
"<script>"
|
||||
" if (confirm('%s: Are you sure?')) {"
|
||||
" xhr = new XMLHttpRequest;"
|
||||
" xhr.open('POST', window.location.href);"
|
||||
" xhr.send(null);"
|
||||
" }"
|
||||
"</script>" % action.capitalize())
|
||||
|
||||
return Response(modal, 200, content_type="text/html")
|
||||
|
||||
if action == "activate":
|
||||
with self.isso.lock:
|
||||
self.comments.activate(id)
|
||||
self.signal("comments.activate", id)
|
||||
else:
|
||||
with self.isso.lock:
|
||||
self.comments.delete(id)
|
||||
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
|
||||
self.signal("comments.delete", id)
|
||||
|
||||
return Response("Yo", 200)
|
||||
|
||||
@requires(str, 'uri')
|
||||
def fetch(self, environ, request, uri):
|
||||
|
||||
args = {
|
||||
'uri': uri,
|
||||
'after': request.args.get('after', 0)
|
||||
}
|
||||
|
||||
try:
|
||||
args['limit'] = int(request.args.get('limit'))
|
||||
except TypeError:
|
||||
args['limit'] = None
|
||||
except ValueError:
|
||||
return BadRequest("limit should be integer")
|
||||
|
||||
if request.args.get('parent') is not None:
|
||||
try:
|
||||
args['parent'] = int(request.args.get('parent'))
|
||||
root_id = args['parent']
|
||||
except ValueError:
|
||||
return BadRequest("parent should be integer")
|
||||
else:
|
||||
args['parent'] = None
|
||||
root_id = None
|
||||
|
||||
plain = request.args.get('plain', '0') == '0'
|
||||
|
||||
reply_counts = self.comments.reply_count(uri, after=args['after'])
|
||||
|
||||
if args['limit'] == 0:
|
||||
root_list = []
|
||||
else:
|
||||
root_list = list(self.comments.fetch(**args))
|
||||
if not root_list:
|
||||
raise NotFound
|
||||
|
||||
if root_id not in reply_counts:
|
||||
reply_counts[root_id] = 0
|
||||
|
||||
try:
|
||||
nested_limit = int(request.args.get('nested_limit'))
|
||||
except TypeError:
|
||||
nested_limit = None
|
||||
except ValueError:
|
||||
return BadRequest("nested_limit should be integer")
|
||||
|
||||
rv = {
|
||||
'id' : root_id,
|
||||
'total_replies' : reply_counts[root_id],
|
||||
'hidden_replies' : reply_counts[root_id] - len(root_list),
|
||||
'replies' : self._process_fetched_list(root_list, plain)
|
||||
}
|
||||
# We are only checking for one level deep comments
|
||||
if root_id is None:
|
||||
for comment in rv['replies']:
|
||||
if comment['id'] in reply_counts:
|
||||
comment['total_replies'] = reply_counts[comment['id']]
|
||||
if nested_limit is not None:
|
||||
if nested_limit > 0:
|
||||
args['parent'] = comment['id']
|
||||
args['limit'] = nested_limit
|
||||
replies = list(self.comments.fetch(**args))
|
||||
else:
|
||||
replies = []
|
||||
else:
|
||||
args['parent'] = comment['id']
|
||||
replies = list(self.comments.fetch(**args))
|
||||
else:
|
||||
comment['total_replies'] = 0
|
||||
replies = []
|
||||
|
||||
comment['hidden_replies'] = comment['total_replies'] - len(replies)
|
||||
comment['replies'] = self._process_fetched_list(replies, plain)
|
||||
|
||||
return JSON(rv, 200)
|
||||
|
||||
def _process_fetched_list(self, fetched_list, plain=False):
|
||||
for item in fetched_list:
|
||||
|
||||
key = item['email'] or item['remote_addr']
|
||||
val = self.cache.get('hash', key.encode('utf-8'))
|
||||
|
||||
if val is None:
|
||||
val = self.hash(key)
|
||||
self.cache.set('hash', key.encode('utf-8'), val)
|
||||
|
||||
item['hash'] = val
|
||||
|
||||
for key in set(item.keys()) - API.FIELDS:
|
||||
item.pop(key)
|
||||
|
||||
if plain:
|
||||
for item in fetched_list:
|
||||
item['text'] = self.isso.render(item['text'])
|
||||
|
||||
return fetched_list
|
||||
|
||||
@xhr
|
||||
def like(self, environ, request, id):
|
||||
|
||||
nv = self.comments.vote(True, id, utils.anonymize(str(request.remote_addr)))
|
||||
return JSON(nv, 200)
|
||||
|
||||
@xhr
|
||||
def dislike(self, environ, request, id):
|
||||
|
||||
nv = self.comments.vote(False, id, utils.anonymize(str(request.remote_addr)))
|
||||
return JSON(nv, 200)
|
||||
|
||||
# TODO: remove someday (replaced by :func:`counts`)
|
||||
@requires(str, 'uri')
|
||||
def count(self, environ, request, uri):
|
||||
|
||||
rv = self.comments.count(uri)[0]
|
||||
|
||||
if rv == 0:
|
||||
raise NotFound
|
||||
|
||||
return JSON(rv, 200)
|
||||
|
||||
def counts(self, environ, request):
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not isinstance(data, list) and not all(isinstance(x, str) for x in data):
|
||||
raise BadRequest("JSON must be a list of URLs")
|
||||
|
||||
return JSON(self.comments.count(*data), 200)
|
||||
|
||||
def demo(self, env, req):
|
||||
return redirect(get_current_url(env) + '/index.html')
|
@ -0,0 +1,30 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pkg_resources
|
||||
dist = pkg_resources.get_distribution("isso")
|
||||
|
||||
import json
|
||||
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from isso import local
|
||||
from isso.compat import text_type as str
|
||||
|
||||
|
||||
class Info(object):
|
||||
|
||||
def __init__(self, conf):
|
||||
self.moderation = conf.getboolean("moderation", "enabled")
|
||||
|
||||
def show(self, environ, request):
|
||||
|
||||
rv = {
|
||||
"version": dist.version,
|
||||
"host": str(local("host")),
|
||||
"origin": str(local("origin")),
|
||||
"moderation": self.moderation,
|
||||
}
|
||||
|
||||
return Response(json.dumps(rv), 200, content_type="application/json")
|
Loading…
Reference in new issue