diff --git a/TODO b/TODO new file mode 100644 index 0000000..7a3840c --- /dev/null +++ b/TODO @@ -0,0 +1,4 @@ + * Put all cookie stuff into *one* securecookie + * Make it possible to tag and find pastes + * Add a button to find all the personal (and private) pastes + (cookie bound) diff --git a/lodgeit/application.py b/lodgeit/application.py index 8c42db0..bc33cb8 100644 --- a/lodgeit/application.py +++ b/lodgeit/application.py @@ -17,7 +17,7 @@ from werkzeug import SharedDataMiddleware, ClosingIterator from werkzeug.exceptions import HTTPException, NotFound from lodgeit.utils import _local_manager, ctx, jinja_environment, \ - Request, generate_user_hash + Request from lodgeit.database import metadata, session, Paste from lodgeit.urls import urlmap from lodgeit.controllers import get_controller diff --git a/lodgeit/controllers/pastes.py b/lodgeit/controllers/pastes.py index f8f697c..b8f9005 100644 --- a/lodgeit/controllers/pastes.py +++ b/lodgeit/controllers/pastes.py @@ -27,18 +27,18 @@ class PasteController(BaseController): """The 'create a new paste' view.""" code = error = '' language = 'text' - pastes = session.query(Paste) - show_captcha = False + show_captcha = private = False + parent = None getform = ctx.request.form.get if ctx.request.method == 'POST': code = getform('code') language = getform('language') - try: - parent = pastes.filter(Paste.paste_id == - int(getform('parent'))).first() - except (ValueError, TypeError): - parent = None + + parent_id = getform('parent') + if parent_id is not None: + parent = Paste.get(parent_id) + spam = ctx.request.form.get('webpage') or antispam.is_spam(code) if spam: error = 'your paste contains spam' @@ -51,16 +51,19 @@ class PasteController(BaseController): show_captcha = True if code and language and not error: paste = Paste(code, language, parent, ctx.request.user_hash) - session.save(paste) + if 'private' in ctx.request.form: + paste.private = True session.flush() return redirect(paste.url) else: - parent = ctx.request.args.get('reply_to') - if parent is not None and parent.isdigit(): - parent = pastes.filter(Paste.paste_id == parent).first() - code = parent.code - language = parent.language + parent_id = ctx.request.args.get('reply_to') + if parent_id is not None: + parent = Paste.get(parent_id) + if parent is not None: + code = parent.code + language = parent.language + private = parent.private return render_template('new_paste.html', languages=LANGUAGES, @@ -68,14 +71,14 @@ class PasteController(BaseController): code=code, language=language, error=error, - show_captcha=show_captcha + show_captcha=show_captcha, + private=private ) - def show_paste(self, paste_id, raw=False): + def show_paste(self, identifier, raw=False): """Show an existing paste.""" linenos = ctx.request.args.get('linenos') != 'no' - pastes = session.query(Paste) - paste = pastes.filter(Paste.c.paste_id == paste_id).first() + paste = Paste.get(identifier) if paste is None: raise NotFound() if raw: @@ -90,18 +93,18 @@ class PasteController(BaseController): linenos=linenos, ) - def raw_paste(self, paste_id): + def raw_paste(self, identifier): """Show an existing paste in raw mode.""" - return self.show_paste(paste_id, raw=True) + return self.show_paste(identifier, raw=True) - def show_tree(self, paste_id): + def show_tree(self, identifier): """Display the tree of some related pastes.""" - paste = Paste.resolve_root(paste_id) + paste = Paste.resolve_root(identifier) if paste is None: raise NotFound() return render_template('paste_tree.html', paste=paste, - current=paste_id + current=identifier ) def show_all(self, page=1): @@ -112,31 +115,30 @@ class PasteController(BaseController): return '/all/' return '/all/%d' % page - pastes = session.query(Paste).order_by( - Paste.c.pub_date.desc() - ).limit(10).offset(10*(page-1)).all() + all = Paste.find_all() + pastes = all.limit(10).offset(10 * (page -1)).all() if not pastes and page != 1: raise NotFound() return render_template('show_all.html', pastes=pastes, - pagination=generate_pagination(page, 10, - Paste.count(), link), + pagination=generate_pagination(page, 10, all.count(), link), css=get_style(ctx.request)[1] ) def compare_paste(self, new_id=None, old_id=None): """Render a diff view for two pastes.""" # redirect for the compare form box - if old_id is new_id is None: + if old_id is None: old_id = ctx.request.form.get('old', '-1').lstrip('#') new_id = ctx.request.form.get('new', '-1').lstrip('#') return redirect('/compare/%s/%s' % (old_id, new_id)) - pastes = session.query(Paste) - old = pastes.filter(Paste.c.paste_id == old_id).first() - new = pastes.filter(Paste.c.paste_id == new_id).first() + + old = Paste.get(old_id) + new = Paste.get(new_id) if old is None or new is None: raise NotFound() + return render_template('compare_paste.html', old=old, new=new, @@ -145,11 +147,12 @@ class PasteController(BaseController): def unidiff_paste(self, new_id=None, old_id=None): """Render an udiff for the two pastes.""" - pastes = session.query(Paste) - old = pastes.filter(Paste.c.paste_id == old_id).first() - new = pastes.filter(Paste.c.paste_id == new_id).first() + old = Paste.get(old_id) + new = Paste.get(new_id) + if old is None or new is None: raise NotFound() + return Response(old.compare_to(new), mimetype='text/plain') def set_colorscheme(self): diff --git a/lodgeit/controllers/xmlrpc.py b/lodgeit/controllers/xmlrpc.py index 0626f0b..8a4891d 100644 --- a/lodgeit/controllers/xmlrpc.py +++ b/lodgeit/controllers/xmlrpc.py @@ -34,10 +34,15 @@ def pastes_new_paste(language, code, parent_id=None, """ if not language: language = get_language_for(filename or '', mimetype or '') - paste = Paste(code, language, parent_id) - session.save(paste) + parent = None + if parent_id: + parent = Paste.get(parent_id) + if parent is None: + raise ValueError('parent paste not found') + + paste = Paste(code, language, parent) session.flush() - return paste.paste_id + return paste.identifier @exported('pastes.getPaste') @@ -48,8 +53,7 @@ def pastes_get_paste(paste_id): `paste_id`, `code`, `parsed_code`, `pub_date`, `language`, `parent_id`, `url`. """ - paste = session.query(Paste).filter(Paste.c.paste_id == - paste_id).first() + paste = Paste.get(paste_id) if paste is None: return False return paste.to_xmlrpc_dict() @@ -58,11 +62,10 @@ def pastes_get_paste(paste_id): @exported('pastes.getDiff') def pastes_get_diff(old_id, new_id): """Compare the two pastes and return an unified diff.""" - pastes = session.query(Paste) - old = pastes.filter(Paste.c.paste_id == old_id).first() - new = pastes.filter(Paste.c.paste_id == new_id).first() + old = Paste.get(old_id) + new = Paste.get(new_id) if old is None or new is None: - return False + raise ValueError('argument error, paste not found') return old.compare_to(new) @@ -72,10 +75,7 @@ def pastes_get_recent(amount=5): `amount` pastes. """ amount = min(amount, 20) - return [x.to_xmlrpc_dict() for x in - session.query(Paste).order_by( - Paste.pub_date.desc() - ).limit(amount)] + return [x.to_xmlrpc_dict() for x in Paste.find_all().limit(amount)] @exported('pastes.getLast') diff --git a/lodgeit/database.py b/lodgeit/database.py index 4004a82..88ca8ac 100644 --- a/lodgeit/database.py +++ b/lodgeit/database.py @@ -19,7 +19,7 @@ from sqlalchemy.orm import create_session, mapper, backref, relation from sqlalchemy.orm.scoping import ScopedSession from werkzeug import cached_property -from lodgeit.utils import _local_manager, ctx +from lodgeit.utils import _local_manager, ctx, generate_paste_hash from lodgeit.lib.highlighting import highlight, LANGUAGES @@ -29,9 +29,10 @@ metadata = MetaData() pastes = Table('pastes', metadata, Column('paste_id', Integer, primary_key=True), + Column('private_id', String(40), unique=True, nullable=True), Column('code', Text), Column('parent_id', Integer, ForeignKey('pastes.paste_id'), - nullable=True), + nullable=True), Column('pub_date', DateTime), Column('language', String(30)), Column('user_hash', String(40), nullable=True), @@ -40,6 +41,7 @@ pastes = Table('pastes', metadata, class Paste(object): + """Represents a paste.""" def __init__(self, code, language, parent=None, user_hash=None): if language not in LANGUAGES: @@ -54,16 +56,83 @@ class Paste(object): self.handled = False self.user_hash = user_hash + @staticmethod + def get(identifier): + """Return the paste for an identifier. Private pastes must be loaded + with their unique hash and public with the paste id. + """ + if isinstance(identifier, basestring) and not identifier.isdigit(): + return Paste.query.filter(Paste.private_id == identifier).first() + return Paste.query.filter( + (Paste.paste_id == int(identifier)) & + (Paste.private_id == None) + ).first() + + @staticmethod + def find_all(): + """Return a query for all public pastes ordered by the publication + date in reverse order. + """ + return Paste.query.filter(Paste.private_id == None) \ + .order_by(Paste.pub_date.desc()) + + @staticmethod + def count(): + """COunt all pastes.""" + s = select([func.count(pastes.c.paste_id)]) + return session.execute(s).fetchone()[0] + + @staticmethod + def resolve_root(identifier): + """Find the root paste for a paste tree.""" + paste = Paste.get(identifier) + if paste is None: + return + while paste.parent_id is not None: + paste = paste.parent + return paste + + def _get_private(self): + return self.private_id is not None + + def _set_private(self, value): + if not value: + self.private_id = None + return + if self.private_id is None: + while 1: + self.private_id = generate_paste_hash() + paste = Paste.query.filter(Paste.private_id == + self.private_id).first() + if paste is None: + break + private = property(_get_private, _set_private, doc=''' + The private status of the paste. If the paste is private it gets + a unique hash as identifier, otherwise an integer. + ''') + del _get_private, _set_private + + @property + def identifier(self): + """The paste identifier. This is a string, the same the `get` + method accepts. + """ + if self.private: + return self.private_id + return str(self.paste_id) + @property def url(self): - return '/show/%d/' % self.paste_id + """The URL to the paste.""" + return '/show/%s/' % self.identifier def compare_to(self, other, context_lines=4, template=False): + """Compare the paste with another paste.""" udiff = u'\n'.join(difflib.unified_diff( self.code.splitlines(), other.code.splitlines(), - fromfile='Paste #%d' % self.paste_id, - tofile='Paste #%d' % other.paste_id, + fromfile='Paste #%s' % self.identifier, + tofile='Paste #%s' % other.identifier, lineterm='', n=context_lines )) @@ -75,9 +144,11 @@ class Paste(object): @cached_property def parsed_code(self): + """The paste as rendered code.""" return highlight(self.code, self.language) def to_xmlrpc_dict(self): + """Convert the paste into a dict for XMLRCP.""" from lodgeit.lib.xmlrpc import strip_control_chars return { 'paste_id': self.paste_id, @@ -90,6 +161,7 @@ class Paste(object): } def render_preview(self): + """Render a preview for this paste.""" try: start = self.parsed_code.index('') code = self.parsed_code[ @@ -110,7 +182,7 @@ class Paste(object): Paste.user_hash == ctx.request.user_hash ) - paste_list = session.query(Paste).filter(and_( + paste_list = Paste.query.filter(and_( Paste.parent_id.in_(s), Paste.handled == False, Paste.user_hash != ctx.request.user_hash, @@ -121,24 +193,8 @@ class Paste(object): values={'handled': True})) return paste_list - @staticmethod - def count(): - s = select([func.count(pastes.c.paste_id)]) - return session.execute(s).fetchone()[0] - @staticmethod - def resolve_root(paste_id): - pastes = session.query(Paste) - while True: - paste = pastes.filter(Paste.c.paste_id == paste_id).first() - if paste is None: - return - if paste.parent_id is None: - return paste - paste_id = paste.parent_id - - -mapper(Paste, pastes, properties={ +session.mapper(Paste, pastes, properties={ 'children': relation(Paste, primaryjoin=pastes.c.parent_id==pastes.c.paste_id, cascade='all', diff --git a/lodgeit/urls.py b/lodgeit/urls.py index ea5fc0c..48089f0 100644 --- a/lodgeit/urls.py +++ b/lodgeit/urls.py @@ -5,7 +5,7 @@ The URL mapping. - :copyright: 2007 by Armin Ronacher. + :copyright: 2007-2008 by Armin Ronacher. :license: BSD """ from werkzeug.routing import Map, Rule @@ -13,12 +13,12 @@ from werkzeug.routing import Map, Rule urlmap = Map([ # paste interface Rule('/', endpoint='pastes/new_paste'), - Rule('/show//', endpoint='pastes/show_paste'), - Rule('/raw//', endpoint='pastes/raw_paste'), + Rule('/show//', endpoint='pastes/show_paste'), + Rule('/raw//', endpoint='pastes/raw_paste'), Rule('/compare/', endpoint='pastes/compare_paste'), - Rule('/compare///', endpoint='pastes/compare_paste'), - Rule('/unidiff///', endpoint='pastes/unidiff_paste'), - Rule('/tree//', endpoint='pastes/show_tree'), + Rule('/compare///', endpoint='pastes/compare_paste'), + Rule('/unidiff///', endpoint='pastes/unidiff_paste'), + Rule('/tree//', endpoint='pastes/show_tree'), # captcha for new paste Rule('/_captcha.png', endpoint='pastes/show_captcha'), diff --git a/lodgeit/utils.py b/lodgeit/utils.py index c4f5328..7d686a9 100644 --- a/lodgeit/utils.py +++ b/lodgeit/utils.py @@ -8,6 +8,7 @@ :copyright: 2007 by Christopher Grebs. :license: BSD """ +import re import time from os import path try: @@ -15,6 +16,7 @@ try: except: from sha import new as sha1 from random import random +from functools import partial from werkzeug import Local, LocalManager, LocalProxy, \ Request as RequestBase, Response @@ -30,9 +32,12 @@ jinja_environment = Environment(loader=FileSystemLoader( path.join(path.dirname(__file__), 'views'))) +_word_only = partial(re.compile(r'[^a-zA-Z0-9]').sub, '') + + def datetimeformat(obj): """Helper filter for the template""" - return obj.strftime('%Y-%m-%d %H:%M') + return obj.strftime('%Y-%m-%d @ %H:%M') jinja_environment.filters['datetimeformat'] = datetimeformat @@ -42,6 +47,16 @@ def generate_user_hash(): return sha1('%s|%s' % (random(), time.time())).hexdigest() +def generate_paste_hash(): + """Generates a more or less unique-truncated SHA1 hash.""" + while 1: + digest = sha1('%s|%s' % (random(), time.time())).digest() + val = _word_only(digest.encode('base64').strip().splitlines()[0])[:20] + # sanity check. number only not allowed (though unlikely) + if not val.isdigit(): + return val + + class Request(RequestBase): """Subclass of the `Request` object. automatically creates a new `user_hash` and sets `first_visit` to `True` if it's a new user. diff --git a/lodgeit/views/compare_paste.html b/lodgeit/views/compare_paste.html index ec3d20d..cba0044 100644 --- a/lodgeit/views/compare_paste.html +++ b/lodgeit/views/compare_paste.html @@ -4,8 +4,8 @@ {% block body %}

Differences between the pastes - #{{ old.paste_id }} ({{ old.pub_date|datetimeformat }}) - and #{{ new.paste_id }} ({{ new.pub_date|datetimeformat }}). Download as unified diff. + #{{ old.identifier }} ({{ old.pub_date|datetimeformat }}) + and #{{ new.identifier }} ({{ new.pub_date|datetimeformat }}). Download as unified diff.

{% if diff.chunks %} diff --git a/lodgeit/views/new_paste.html b/lodgeit/views/new_paste.html index 7df4e59..f6069fa 100644 --- a/lodgeit/views/new_paste.html +++ b/lodgeit/views/new_paste.html @@ -10,7 +10,8 @@ {%- if show_captcha %}

Please fill out the CAPTCHA to proceed:

- a captcha you can't see.  Sorry :( + a captcha you can't see.  Sorry :(
{%- else %} @@ -19,7 +20,7 @@ {%- endif %} {%- if parent %} - + {%- endif %} + {% endblock %} diff --git a/lodgeit/views/show_all.html b/lodgeit/views/show_all.html index bc4f13c..1ccb30a 100644 --- a/lodgeit/views/show_all.html +++ b/lodgeit/views/show_all.html @@ -5,7 +5,7 @@