Compare commits

...

60 commits
r0 ... master

Author SHA1 Message Date
Michael Hope b604cce31c niche: add the templates and static files 2020-12-12 13:14:11 +01:00
Michael Hope a1e04d5834 niche: move into a package so it can be installed 2020-12-12 12:35:38 +01:00
Michael Hope 79e47bc39a niche: add pymysql to setup.py 2020-12-11 22:10:29 +01:00
Michael Hope ded2046179 niche: add the markdown dep 2020-12-11 21:50:44 +01:00
Michael Hope 5fd6e981e9 niche: add a stub test case 2020-12-11 21:46:09 +01:00
Michael Hope 4cee16ba96 niche: fix the web.py package name 2020-12-11 21:39:05 +01:00
Michael Hope 71e78fbb8e niche: move from Makefile to setup.py with a version hack 2020-12-11 21:35:27 +01:00
Michael Hope 278193dc5a niche: add a setup.py 2020-12-11 21:21:53 +01:00
Michael Hope 14ed4b9d1f niche: update to Python3 2020-12-11 21:17:16 +01:00
Michael Hope b99a317835 Support HTTPS based redirects. 2015-03-03 20:41:12 +01:00
Michael Hope a376aba8a6 templates: ensure bold blocks are always closed. 2015-03-03 20:40:26 +01:00
Michael Hope e928eae275 Added support for login behind HTTPS. 2014-05-23 21:41:04 +02:00
Michael Hope 1cd4b58142 Added notes on the site performance. 2014-05-23 21:40:22 +02:00
Michael Hope 05989ab593 Use unquote_plus so usernames like /user/foo+the+bar change to spaces. 2014-04-22 20:06:27 +02:00
Michael Hope bbf2b5e06c Fix a whitespace-only extended description turning into a real description. 2014-04-22 19:59:16 +02:00
Michael Hope 52ce6a4a44 PEP8, pylint, and pychecker all the things. 2014-04-22 19:51:34 +02:00
Michael Hope ff7dbc5b4c Switch to using a dirty flag on the whole of the table.
Pull back the cache time to reduce the dirty impact.
2014-04-19 20:50:19 +02:00
Michael Hope e90121ae36 Changed the cache prefix to come from the db name. 2014-04-19 20:24:19 +02:00
Michael Hope ad6c4a4ba6 Make /link/nn do a not found if there's no link.
Pull the memcache configuration out into config.
2014-04-19 20:11:38 +02:00
Michael Hope 92a607f907 Added a simple Makefile with some fetch only tests. 2014-04-19 19:55:25 +02:00
Michael Hope dbdae74266 Added a special '_count' extension which turns into a SQL COUNT(*). 2014-04-18 22:32:01 +02:00
Michael Hope 90d7d1dc7a Added a diediedie handler for killing the server while profiling. 2014-04-18 22:31:19 +02:00
Michael Hope 81ce9b2203 Added a memcache based database cache.
Turn off debug by default.
2014-04-18 22:29:59 +02:00
Michael Hope b9bc4f5347 Added imports from the previous database. 2014-04-17 20:40:59 +02:00
Michael Hope 1b39c19971 Re-worked the ordering of the by line to allow comment links.
Re-worked the layout of the sidebar.
Exclude the users own comments from the sidebar.
2014-04-17 20:17:16 +02:00
Michael Hope a3edebf092 Tweak how string variable names are generated.
Add highlighting on wrong inputs.
2014-04-17 19:56:09 +02:00
Michael Hope 9843c00dec Add CSRF tokens to all forms. 2014-04-17 19:55:55 +02:00
Michael Hope 3c464e3427 Tweaked the recent comments list. 2014-04-17 18:53:48 +02:00
Michael Hope 59abf6b5a2 Change back to three days of history. 2014-02-23 21:08:46 +01:00
Michael Hope 3d52cb0024 Fix misreporting of the comment age. 2014-02-23 21:06:06 +01:00
Michael Hope 61978c7c08 Added a 'new' section to the sidebar. Currently goes back a fixed
number of days.
2014-02-23 21:04:35 +01:00
Michael Hope f4ddba1a4f Support an optional site wide license.
Fixes #29.
2014-02-23 20:25:42 +01:00
Michael Hope 2c072be6bc Use a (English only) helper to pluralise numbers.
Fixes #20.
2014-02-23 20:18:43 +01:00
Michael Hope f8f9e300cf Added MonkeyFilter compatible URLs. 2014-02-23 20:04:58 +01:00
Michael Hope 460083fc1b Added a Counters class for bumping event counters.
Added counters to most events.
2014-02-23 19:55:32 +01:00
Michael Hope 414b18b17f Made password boxes the same width as text.
Added a 'contact us on lost password' email link.
2013-10-10 22:15:44 +02:00
Michael Hope dcb391b86d Add a little bit of margin to the checkboxes. 2013-10-10 22:09:01 +02:00
Michael Hope 12953d9616 Tidied up all of the forms. Now uses CSS.
Added a hook to automatically add descriptions and style to forms.
Now need the 'current password' when changing the password.
Changed 'content' to 'comment' for the new comment form.
2013-10-10 22:05:49 +02:00
Michael Hope 33ad21941d Prettified the new comment page. 2013-10-09 21:15:14 +02:00
Michael Hope edc008866a Added a JSON based generic type.
Added user editing.
Fixed rendering of the bio.
2013-10-09 20:49:02 +02:00
Michael Hope a7c96ebd49 web.config.debug needs to be set before anything else is created.
Tidy up the rest in preperation for a hack.
2013-10-03 22:29:41 +02:00
Michael Hope a627150757 Revert "Add simple function call count tracking."
This reverts commit b9bd774076.
2013-10-03 22:08:58 +02:00
Michael Hope b9bd774076 Add simple function call count tracking. 2013-10-03 21:50:44 +02:00
Michael Hope 53f29c3e4d Added an option to turn off auto reload. Not the best as fastcgi and
autoreload are normally tied.
2013-10-01 21:17:46 +02:00
Michael Hope 1f0a2df53b Added an internal page for tracking the object diff between GC runs.
Should show the type of any leaks.
2013-10-01 21:12:33 +02:00
Michael Hope a305bd6e08 Widened the default tags to match Monkeyfilter. 2013-10-01 19:56:27 +02:00
Michael Hope a78e763cac Fixed the permissions on the feed icon. Execute means nginx won't serve it. 2013-09-30 21:48:24 +02:00
Michael Hope d19f0c99d7 Bind in RSS.
Bind in RSS to the layout page.
Add a checkout of all of a users links.
Put RSS and checkout behind feature flags.
2013-09-30 21:39:42 +02:00
Michael Hope 4f89ff4bd8 Bound the RSS feed into the layout and footer.
Put RSS and checkout behind a feature flag.
2013-09-30 21:35:08 +02:00
Michael Hope 4ac23c37fb Added a RSS feed at /rss.
Added a checkout fo all of a users links.
2013-09-30 21:25:53 +02:00
Michael Hope af12a3d3dd Let the admin set a users password.
First pass at pylint.
2013-09-30 20:18:41 +02:00
Michael Hope beb8bc2328 Change how the server type (dev vs FastCGI) is set. 2013-09-29 21:16:04 +02:00
Michael Hope 020bbccdb3 Fix the archive by limiting the end date to 2037. Needed on brik as
its got an old libc.
Add a simple filter to the archive.
2013-09-29 20:18:21 +02:00
Michael Hope 873b4c2393 Added Markdown support for links and comments.
Split ConfigParser out so the defaults and a lists are common.
2013-09-29 15:58:06 +02:00
Michael Hope 5cd023b9b4 Fixed the link view to match the front page 'links' formatting.
Changed the comment box to always show.
2013-09-29 15:11:23 +02:00
Michael Hope 1c54208910 Shifted where the message box goes so it wraps to the main content area. 2013-09-29 15:10:54 +02:00
Michael Hope e1a73723cd Pulled the niche top level stylesheet back in to get alert highlighting. 2013-09-29 15:10:18 +02:00
Michael Hope 217fcb61f0 Added the html5 shim to support IE8 and earlier. 2013-09-29 14:48:28 +02:00
Michael Hope 24bf894668 Did proper, auto-sizing pagination. 2013-09-28 21:25:03 +02:00
Michael Hope 31da4a2259 Always include the revno in the version. 2013-09-28 21:01:03 +02:00
30 changed files with 1853 additions and 728 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
*.pyc
ignore/
sessions/
version.py

View file

@ -18,3 +18,31 @@ mysql -u niche -p niche < schema.sql
Sanitizing
----------
bleach looks fine https://github.com/jsocol/bleach
Performance
-----------
All with niche-dev running a local server.
base: 1592 ms
web.config.debug = False: 841 ms
Pre-compiled templates: 848 ms
Basic MySQL caching: ~366 ms
cProfile doesn't do multiple threads.
HTML templates: 366 ms
Precompiled templates: 360 ms (no advantage)
Baseline: 366 ms
Basic caching: 228 ms
Binary pickle: 221 ms
Before launch
-------------
Error and access logging: DONE
Check /static/ is served by nginx: DONE
Check gzip effects: DONE
Way of logging bytes transferred:
error.log has no SQL in it: DONE
memcached in monit: DONE
monit emails sending: DONE

610
niche.py
View file

@ -1,610 +0,0 @@
#!/usr/bin/python
import datetime
import hashlib
import ConfigParser
import passlib
import re
import time
import subprocess
import web
import bleach
from passlib.apps import custom_app_context as pwd_context
import strings
import utils
urls = (
'/?', 'index',
'/links(/\d+)?(/\d+)?(/\d+)?', 'links',
'/link/new', 'new_link',
'/link/(\d+)', 'link',
'/link/(\d+)/hide', 'hide_link',
'/link/(\d+)/close', 'close_link',
'/link/(\d+)/new', 'new_comment',
'/comment/(\d+)/delete', 'delete_comment',
'/comment/(\d+)/like', 'like_comment',
'/user/([^/]+)', 'user',
'/user/([^/]+)/links', 'user_links',
'/user/([^/]+)/comments', 'user_comments',
'/user/([^/]+)/password', 'password',
'/login', 'login',
'/logout', 'logout',
'/newuser', 'newuser',
)
# Default configuration
DEFAULTS = [
( 'general', {
'dateformat': '%B %d, %Y',
'base': '/',
'wsgi': 'false',
'limit': 50,
}),
( 'groups', {
'admins': '',
}),
( 'db', {
'db': 'niche',
'user': 'niche',
'password': 'whatever',
}),
( 'site', {
'name': 'Nichefilter',
'subtitle': 'of no fixed subtitle',
}),
]
def read_config():
"""Set up the defaults and read in niche.ini, if any."""
cfg = ConfigParser.RawConfigParser()
for section, items in DEFAULTS:
cfg.add_section(section)
for name, value in items.items():
cfg.set(section, name, value)
cfg.read('niche.ini')
return cfg
config = read_config()
FEATURES = 'likes gravatar'.split()
def get_features(config):
features = web.utils.Storage()
for feature in FEATURES:
features[feature] = config.has_option('features', feature) and config.getboolean('features', feature)
return features
features = get_features(config)
def get_version():
return subprocess.check_output('git describe --always --dirty'.split()).strip()
def get_string(id):
"""Get a string gettext style. Splits the strings from the
code.
"""
id = id.lower().replace(' ', '_').replace("'", "")
return strings.__dict__[id]
_ = get_string
db = web.database(dbn='mysql',
user=config.get('db', 'user'),
pw=config.get('db', 'password'),
db=config.get('db','db'),
)
def require_feature(name):
if not features[name]:
raise web.notfound()
def now():
return time.time()
fallbacks = {
'user': web.utils.Storage(username='anonymous'),
}
class AutoMapper:
def __init__(self, type, around):
self._type = type
self._around = around
def __getattr__(self, name):
if hasattr(self._around, name):
return getattr(self._around, name)
key = '%sID' % name
if hasattr(self._around, key):
id = getattr(self._around, key)
got = first_or_none(name, key, id)
if got:
return got
if name in fallbacks:
return fallbacks[name]
raise web.notfound()
if name.endswith('s'):
singular = name[:-1]
table = '1_%s' % name
assert self._type
key = '%sID' % self._type
rows = db.select(table, where='%s = $id' % key, vars={'id': getattr(self, key)})
return [AutoMapper(singular, x) for x in rows]
raise AttributeError(name)
def ago(self):
return utils.ago(self.timestamp)
def to_date(self):
"""Convert a timestamp to a date object."""
return datetime.date.fromtimestamp(self.timestamp)
def to_datestr(self):
"""Convert a timestamp to a date string."""
return self.to_date().strftime(config.get('general', 'dateformat'))
def to_date_link(self):
"""Convert a timestamp to a link."""
date = self.to_date()
return '%04d/%02d/%02d' % (date.year, date.month, date.day)
def first_or_none(type, column, id, strict=False):
"""Get the first item in the table that matches or None if there's
no match.
"""
table = '1_%ss' % type
vs = db.select(table, where='%s = $id' % column, vars={'id': id}, limit=1)
if len(vs):
return AutoMapper(type, vs[0])
elif strict:
raise web.notfound()
else:
return None
def first(type, column, id):
"""Get the first matching item in the table or raise not found."""
return first_or_none(type, column, id, strict=True)
class Model:
"""Top level helpers. Exposed to scripts."""
def is_admin(self):
id = session.get('userID', None)
return id != None and (str(id) in config.get('groups', 'admins').split())
def get_link(self, id):
"""Get a link by link ID"""
return first_or_none('link', 'linkID', id)
def get_comment(self, id):
"""Get a comment by comment ID"""
return first_or_none('comment', 'commentID', id, strict=True)
def get_user(self, id):
"""Get a user by user ID"""
return first_or_none('user', 'userID', id)
def get_gravatar(self, email):
"""Get the gravatar hash for an email"""
return hashlib.md5(email.strip().lower()).hexdigest()
def get_message(self):
"""Get the message for the user, if any, and clear"""
message = session.get('message', None)
if message:
session.message = None
return message
def inform(self, message):
"""Log a message to show the user on the next page"""
session.message = message
def get_active(self):
"""Get the user entry for the currently logged in user, or
None.
"""
id = session.get('userID', None)
if not id:
return None
return first_or_none('user', 'userID', id)
model = Model()
render = web.template.render(
'templates/',
base='layout',
globals={
'model': model,
'config': config,
'features': features,
'version': get_version(),
},
)
app = web.application(urls, locals())
def make_session():
"""Helper that makes the session object, even if in debug mode."""
if web.config.get('_session') is None:
session = web.session.Session(app, web.session.DiskStore('sessions'),
initializer={'message': None}
)
web.config._session = session
else:
session = web.config._session
return session
session = make_session()
# Validate a password. Pretty lax.
password_validator = web.form.Validator(_("Short password"), lambda x: len(x) >= 3)
def url_validator(v):
if not v:
return True
return re.match('(http|https|ftp|mailto)://.+', v)
def redirect(url):
"""Bounce to a different site absolute URL."""
raise web.seeother(url)
def authenticate(msg=_("Login required")):
if not session.get('userID', None):
model.inform(msg)
redirect('/login')
def need_admin(msg):
if not model.is_admin():
model.inform(msg)
redirect('/login')
def error(message, condition, target='/'):
"""Log an error if condition is true and bounce to somewhere."""
if condition:
model.inform(message)
redirect(target)
def render_input(v):
"""Tidy up user input and insert breaks for empty lines."""
v = bleach.clean(v)
out = ''
for line in v.split('\n'):
if not line.strip():
out += '<br/>\n'
else:
out += line + '\n'
return out
def render_links(where=None, span=None, vars={}):
input = web.input()
offset = int(input.get('offset', 0))
offset = max(offset, 0)
limit = int(input.get('limit', config.get('general', 'limit')))
limit = max(0, min(200, limit))
links = db.select('1_links', where=where, vars=vars, limit=limit, offset=offset, order="timestamp DESC")
if where:
results = db.query("SELECT COUNT(*) AS total FROM 1_links WHERE %s" % where, vars=vars)
else:
results = db.query("SELECT COUNT(*) AS total FROM 1_links")
total = results[0].total
return render.links([AutoMapper('link', x) for x in links], span, web.ctx.path, offset, limit, total)
class index:
def GET(self):
return render_links()
class links:
def GET(self, year, month, day):
def tidy(v, low, high):
"""Turn an optional parameter into a validated number"""
if v:
v = int(v[1:])
if v < low or v > high:
raise web.notfound()
return False, v
else:
return True, low
no_year, year = tidy(year, 1990, 2037)
no_month, month = tidy(month, 1, 12)
no_day, day = tidy(day, 1, 31)
start = datetime.date(year, month, day)
span = None
# Figure out the span based on what was supplied
if no_year:
end = datetime.date(year + 100, month, day)
span = 'years'
elif no_month:
end = datetime.date(year + 1, month, day)
span = 'months'
elif no_day:
if month == 12:
end = datetime.date(year + 1, 1, day)
else:
end = datetime.date(year, month + 1, day)
else:
end = start + datetime.timedelta(days=1)
tstart = time.mktime(start.timetuple())
tend = time.mktime(end.timetuple())
return render_links(where='timestamp >= $tstart and timestamp < $tend',
vars={ 'tstart': tstart, 'tend': tend }, span=span)
class link:
def GET(self, id):
link = model.get_link(id)
return render.link(link, None, False)
class new_link:
form = web.form.Form(
web.form.Textbox('title', web.form.notnull),
web.form.Textbox('url', web.form.Validator(_("Not a URL"), url_validator)),
web.form.Textbox('url_description'),
web.form.Textarea('description', rows=10, cols=80),
web.form.Textarea('extended', rows=10, cols=80),
validators = [
web.form.Validator(_("URLs need a description"), lambda x: x.url_description if x.url else True),
web.form.Validator(_("Need a URL or description"), lambda x: x.url or x.description),
]
)
def authenticate(self):
authenticate(_("Login to post"))
def GET(self):
self.authenticate()
return render.new_link(self.form(), None)
def POST(self):
self.authenticate()
form = self.form()
if not form.validates():
return render.new_link(form, None)
user = model.get_active()
url_description = render_input(form.d.url_description)
description = render_input(form.d.description)
extended = render_input(form.d.extended)
if 'preview' in web.input():
preview = web.utils.Storage(
title=form.d.title,
URL=form.d.url,
URL_description=url_description,
description=description,
extended=extended)
return render.new_link(form, preview)
next = db.insert('1_links',
userID=user.userID,
timestamp=now(),
title=form.d.title,
URL=form.d.url,
URL_description=url_description,
description=description,
extended=extended
)
model.inform(_("New post success"))
redirect('/link/%d' % next)
class hide_link:
def GET(self, id):
link = model.get_link(id)
need_admin(_('Admin needed to hide a link'))
next = not link.hidden
db.update('1_links', where='linkID = $id', hidden=next, vars={'id': id})
model.inform(_("Link is hidden") if next else _("Link now shows"))
redirect('/link/%s' % id)
class close_link:
def GET(self, id):
link = model.get_link(id)
need_admin(_('Admin needed to close a link'))
next = not link.closed
db.update('1_links', where='linkID = $id', closed=next, vars={'id': id})
model.inform(_("Link is closed") if next else _("Link is open"))
redirect('/link/%s' % id)
class new_comment:
form = web.form.Form(
web.form.Textarea('content', web.form.notnull, rows=10, cols=80)
)
def check(self, id):
authenticate(_("Login to comment"))
link = model.get_link(id)
error(_("Link is closed"), link.closed)
return link
def GET(self, id):
link = self.check(id)
return render.link(link, self.form(), None)
def POST(self, id):
link = self.check(id)
form = self.form()
if not form.validates():
return render.link(link, form, None)
user = model.get_active()
content = render_input(form.d.content)
if 'preview' in web.input():
return render.link(link, form, content)
next = db.insert('1_comments',
linkID=link.linkID,
userID=user.userID,
timestamp=now(),
content=content
)
model.inform(_("New comment success"))
redirect('/link/%d' % link.linkID)
class delete_comment:
def GET(self, id):
comment = model.get_comment(id)
need_admin(_('Admin needed to delete a comment'))
db.delete('1_comments', where='commentID = $id', vars={'id': id})
model.inform(_("Comment deleted"))
redirect('/link/%s' % comment.linkID)
class like_comment:
def GET(self, id):
check_feature('likes')
authenticate(_("Login to like"))
comment = model.get_comment(id)
userID = session.userID
db.insert('1_likes', commentID=comment.commentID, userID=userID)
model.inform(_("Liked"))
redirect('/link/%s' % comment.linkID)
class user:
def GET(self, id):
user = first('user', 'username', id)
return render.user(user)
class user_links:
def GET(self, id):
user = first('user', 'username', id)
return render_links(where='userID=$id', vars={'id': user.userID})
class user_comments:
def GET(self, id):
user = first('user', 'username', id)
comments = db.select('1_comments', where='userID=$id', order='timestamp DESC',
vars={'id': user.userID},
limit=config.get('general', 'limit'))
return render.user_comments([AutoMapper('comment', x) for x in comments])
class login:
login = web.form.Form(
web.form.Textbox('username', web.form.notnull),
web.form.Password('password', web.form.notnull),
)
def GET(self):
return render.login(self.login())
def POST(self):
form = self.login()
if not form.validates():
return render.login(form)
user = first_or_none('user', 'username', form.d.username)
ok = False
if user:
try:
ok = passlib.hash.mysql323.verify(form.d.password, user.password)
except ValueError:
ok = pwd_context.verify(form.d.password, user.password)
if not ok:
form.valid = False
model.inform(_("Bad username or password"))
return render.login(form)
session.userID = user.userID
model.inform(_("Logged in"))
redirect('/')
class logout:
def GET(self):
session.userID = None
model.inform(_("Logged out"))
redirect('/')
class password:
form = web.form.Form(
web.form.Password('password', web.form.notnull, password_validator,
description=_("New password")),
web.form.Password('again', web.form.notnull, description=_("Password again")),
validators=[
web.form.Validator(_("Passwords don't match"), lambda x: x.password == x.again)
]
)
def authenticate(self, id):
authenticate()
active = model.get_active()
error(_("Permission denied"), active.username != id, '/user/%s' % id)
def GET(self, id):
self.authenticate(id)
return render.password(self.form())
def POST(self, id):
self.authenticate(id)
form = self.form()
if not form.validates():
return render.login(form)
db.update('1_users', password=pwd_context.encrypt(form.d.password), where='username=$id', vars={'id': id})
model.inform(_("Password changed"))
redirect('/user/%s' % id)
if __name__ == "__main__":
if config.getboolean('general', 'wsgi'):
web.config.debug = False
web.wsgi.runwsgi = lambda func, addr=None: web.wsgi.runfcgi(func, addr)
else:
# Development machine. Run stand alone
pass
app.run()

0
niche/__init__.py Normal file
View file

4
niche/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from . import app
if __name__ == "__main__":
app.main()

1190
niche/app.py Executable file

File diff suppressed because it is too large Load diff

View file

@ -26,3 +26,29 @@ admin_needed_to_delete_a_comment = 'Admin needed to delete a comment'
admin_needed_to_hide_a_link = 'Admin needed to hide a link'
admin_needed_to_close_a_link = 'Admin needed to close a link'
bad_username_or_password = "Bad username or password"
only_the_user_can_checkout_their_links = "Only the user can checkout their links"
possible_cross_site_request_forgery_try_again = "Possible cross site request forgery. Try again."
only_admins_can_access_debug_pages = "Only admins can access debug pages."
field_realname = "Real name"
field_email = "Email"
field_gravatar_email = "Gravatar email"
field_homepage = "Homepage"
field_team = "Team"
field_location = "Location"
field_twitter = "Twitter"
field_facebook = "Facebook"
field_google_plus = "Google+"
field_skype = "Skype"
field_aim = "AIM"
field_bio = "Bio"
field_title = "Title"
field_url = "URL"
field_url_description = "URL description"
field_extended = "Extended"
field_description = "Description"
field_use_markdown = "Use Markdown"
field_comment = "Comment"
field_username = "Username"
field_password = "Password"
field_new_password = "New password"
field_again = "Again"

2
niche/test_niche.py Normal file
View file

@ -0,0 +1,2 @@
def test_loads():
import niche

View file

@ -1,4 +1,30 @@
import collections
import copy
import time
import threading
class Counters(object):
def __init__(self):
self.counters = collections.defaultdict(int)
self.lock = threading.Lock()
self.counters['id'] = id(self)
def bump(self, name, arg=None):
if not isinstance(name, str):
name = name.__class__.__name__
if '.' in name:
name = name[name.rindex('.')+1:]
if arg:
name = '%s/%s' % (name, arg)
with self.lock:
self.counters[name] += 1
def get_snapshot(self):
with self.lock:
return copy.copy(self.counters)
def now():
"""Return the current time in the right epoch."""

1
niche/version.py Normal file
View file

@ -0,0 +1 @@
__version__ = 'r1-5-gb99a317-dirty'

View file

@ -144,6 +144,7 @@ CREATE TABLE `1_users` (
`bio` mediumtext NOT NULL,
`preferences` text,
`last_visit` int(14) unsigned NOT NULL default '0',
`contacts` text,
PRIMARY KEY (`userID`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;

30
setup.py Normal file
View file

@ -0,0 +1,30 @@
import glob
from setuptools import find_packages, setup
setup(
name='niche',
version_format='{tag}.dev{commitcount}+{gitsha}',
setup_requires=['setuptools-git-version'],
url='https://juju.nz/src/juju/niche',
author='Michael Hope',
author_email='michaelh@juju.nz',
description='A community web log for monkey niches.',
zip_safe=False,
packages=find_packages(),
data_files=[
('lib/niche/templates', glob.glob('templates/*')),
('lib/niche/static/themes/common',
glob.glob('static/themes/common/*')),
('lib/niche/static/themes/able-wpcom',
glob.glob('static/themes/able-wpcom/*')),
],
install_requires=[
'passlib>=1.7.4',
'bleach>=3.2.1',
'markdown>=3.1.1',
'python-memcached>=1.59',
'web.py>=0.62',
'pymysql>=0.9.3',
],
)

View file

@ -1,54 +0,0 @@
body {
font-family: Ubuntu, Tahoma, Geneva;
}
h1 {
background-color: yellow;
margin: 0;
padding: 0.3em;
border-bottom: 1px solid #B0B000;
}
h1 a {
color: black;
text-decoration: none;
}
.meta {
background-color: lightblue;
border: 1px solid blue;
padding: 0.5em;
}
.message {
background-color: lightgreen;
border: 1px solid green;
padding: 0.5em;
}
.error {
background-color: pink;
border: 1px solid red;
padding: 0.5em;
}
.wrong {
}
.preview {
background-color: #e0e0e0;
border: 1px solid #101010;
padding: 0.5em;
}
.link {
margin: 0em 0em 0.5em 1em;
}
.comment {
border-top: 1px solid #808080;
}
.byline {
font-size: small;
}

View file

@ -0,0 +1,288 @@
/*! HTML5 Shiv v3.6.1 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed */
;(function(window, document) {
/*jshint evil:true */
/** Preset options */
var options = window.html5 || {};
/** Used to skip problem elements */
var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i;
/** Not all elements can be cloned in IE **/
var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i;
/** Detect whether the browser supports default html5 styles */
var supportsHtml5Styles;
/** Name of the expando, to work with multiple documents or to re-shiv one document */
var expando = '_html5shiv';
/** The id for the the documents expando */
var expanID = 0;
/** Cached data for each document */
var expandoData = {};
/** Detect whether the browser supports unknown elements */
var supportsUnknownElements;
(function() {
try {
var a = document.createElement('a');
a.innerHTML = '<xyz></xyz>';
//if the hidden property is implemented we can assume, that the browser supports basic HTML5 Styles
supportsHtml5Styles = ('hidden' in a);
supportsUnknownElements = a.childNodes.length == 1 || (function() {
// assign a false positive if unable to shiv
(document.createElement)('a');
var frag = document.createDocumentFragment();
return (
typeof frag.cloneNode == 'undefined' ||
typeof frag.createDocumentFragment == 'undefined' ||
typeof frag.createElement == 'undefined'
);
}());
} catch(e) {
supportsHtml5Styles = true;
supportsUnknownElements = true;
}
}());
/*--------------------------------------------------------------------------*/
/**
* Creates a style sheet with the given CSS text and adds it to the document.
* @private
* @param {Document} ownerDocument The document.
* @param {String} cssText The CSS text.
* @returns {StyleSheet} The style element.
*/
function addStyleSheet(ownerDocument, cssText) {
var p = ownerDocument.createElement('p'),
parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement;
p.innerHTML = 'x<style>' + cssText + '</style>';
return parent.insertBefore(p.lastChild, parent.firstChild);
}
/**
* Returns the value of `html5.elements` as an array.
* @private
* @returns {Array} An array of shived element node names.
*/
function getElements() {
var elements = html5.elements;
return typeof elements == 'string' ? elements.split(' ') : elements;
}
/**
* Returns the data associated to the given document
* @private
* @param {Document} ownerDocument The document.
* @returns {Object} An object of data.
*/
function getExpandoData(ownerDocument) {
var data = expandoData[ownerDocument[expando]];
if (!data) {
data = {};
expanID++;
ownerDocument[expando] = expanID;
expandoData[expanID] = data;
}
return data;
}
/**
* returns a shived element for the given nodeName and document
* @memberOf html5
* @param {String} nodeName name of the element
* @param {Document} ownerDocument The context document.
* @returns {Object} The shived element.
*/
function createElement(nodeName, ownerDocument, data){
if (!ownerDocument) {
ownerDocument = document;
}
if(supportsUnknownElements){
return ownerDocument.createElement(nodeName);
}
if (!data) {
data = getExpandoData(ownerDocument);
}
var node;
if (data.cache[nodeName]) {
node = data.cache[nodeName].cloneNode();
} else if (saveClones.test(nodeName)) {
node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode();
} else {
node = data.createElem(nodeName);
}
// Avoid adding some elements to fragments in IE < 9 because
// * Attributes like `name` or `type` cannot be set/changed once an element
// is inserted into a document/fragment
// * Link elements with `src` attributes that are inaccessible, as with
// a 403 response, will cause the tab/window to crash
// * Script elements appended to fragments will execute when their `src`
// or `text` property is set
return node.canHaveChildren && !reSkip.test(nodeName) ? data.frag.appendChild(node) : node;
}
/**
* returns a shived DocumentFragment for the given document
* @memberOf html5
* @param {Document} ownerDocument The context document.
* @returns {Object} The shived DocumentFragment.
*/
function createDocumentFragment(ownerDocument, data){
if (!ownerDocument) {
ownerDocument = document;
}
if(supportsUnknownElements){
return ownerDocument.createDocumentFragment();
}
data = data || getExpandoData(ownerDocument);
var clone = data.frag.cloneNode(),
i = 0,
elems = getElements(),
l = elems.length;
for(;i<l;i++){
clone.createElement(elems[i]);
}
return clone;
}
/**
* Shivs the `createElement` and `createDocumentFragment` methods of the document.
* @private
* @param {Document|DocumentFragment} ownerDocument The document.
* @param {Object} data of the document.
*/
function shivMethods(ownerDocument, data) {
if (!data.cache) {
data.cache = {};
data.createElem = ownerDocument.createElement;
data.createFrag = ownerDocument.createDocumentFragment;
data.frag = data.createFrag();
}
ownerDocument.createElement = function(nodeName) {
//abort shiv
if (!html5.shivMethods) {
return data.createElem(nodeName);
}
return createElement(nodeName, ownerDocument, data);
};
ownerDocument.createDocumentFragment = Function('h,f', 'return function(){' +
'var n=f.cloneNode(),c=n.createElement;' +
'h.shivMethods&&(' +
// unroll the `createElement` calls
getElements().join().replace(/\w+/g, function(nodeName) {
data.createElem(nodeName);
data.frag.createElement(nodeName);
return 'c("' + nodeName + '")';
}) +
');return n}'
)(html5, data.frag);
}
/*--------------------------------------------------------------------------*/
/**
* Shivs the given document.
* @memberOf html5
* @param {Document} ownerDocument The document to shiv.
* @returns {Document} The shived document.
*/
function shivDocument(ownerDocument) {
if (!ownerDocument) {
ownerDocument = document;
}
var data = getExpandoData(ownerDocument);
if (html5.shivCSS && !supportsHtml5Styles && !data.hasCSS) {
data.hasCSS = !!addStyleSheet(ownerDocument,
// corrects block display not defined in IE6/7/8/9
'article,aside,figcaption,figure,footer,header,hgroup,nav,section{display:block}' +
// adds styling not present in IE6/7/8/9
'mark{background:#FF0;color:#000}'
);
}
if (!supportsUnknownElements) {
shivMethods(ownerDocument, data);
}
return ownerDocument;
}
/*--------------------------------------------------------------------------*/
/**
* The `html5` object is exposed so that more elements can be shived and
* existing shiving can be detected on iframes.
* @type Object
* @example
*
* // options can be changed before the script is included
* html5 = { 'elements': 'mark section', 'shivCSS': false, 'shivMethods': false };
*/
var html5 = {
/**
* An array or space separated string of node names of the elements to shiv.
* @memberOf html5
* @type Array|String
*/
'elements': options.elements || 'abbr article aside audio bdi canvas data datalist details figcaption figure footer header hgroup mark meter nav output progress section summary time video',
/**
* A flag to indicate that the HTML5 style sheet should be inserted.
* @memberOf html5
* @type Boolean
*/
'shivCSS': (options.shivCSS !== false),
/**
* Is equal to true if a browser supports creating unknown/HTML5 elements
* @memberOf html5
* @type boolean
*/
'supportsUnknownElements': supportsUnknownElements,
/**
* A flag to indicate that the document's `createElement` and `createDocumentFragment`
* methods should be overwritten.
* @memberOf html5
* @type Boolean
*/
'shivMethods': (options.shivMethods !== false),
/**
* A string to describe the type of `html5` object ("default" or "default print").
* @memberOf html5
* @type String
*/
'type': 'default',
// shivs the document according to the specified `html5` object options
'shivDocument': shivDocument,
//creates a shived element
createElement: createElement,
//creates a shived documentFragment
createDocumentFragment: createDocumentFragment
};
/*--------------------------------------------------------------------------*/
// expose html5
window.html5 = html5;
// shiv the document
shivDocument(document);
}(this, document));

View file

@ -1276,6 +1276,13 @@ article.comment {
#commentform input[ type="text" ] {
display: block;
}
#commentform input[ type="password" ] {
display: block;
}
#commentform input[ type="checkbox" ] {
margin-left: 0.5em;
}
#commentform .required {
color: rgb( 255, 0, 0 ); /* #ff0000 */
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

View file

@ -0,0 +1,36 @@
.new {
background-color: lightyellow;
border: 1px solid yellow;
padding: 0.3em;
font-size: small;
}
.meta {
background-color: lightblue;
border: 1px solid blue;
padding: 0.3em;
}
.message {
background-color: lightgreen;
border: 1px solid green;
padding: 0.3em;
}
.error {
background-color: pink;
border: 1px solid red;
padding: 0.3em;
}
.wrong {
background-color: pink;
border: 1px solid red;
padding: 0.3em;
}
.preview {
background-color: #e0e0e0;
border: 1px solid #101010;
padding: 0.3em;
}

View file

@ -0,0 +1,8 @@
$def with (title, diffs)
<h1>$title</h1>
<table border=1>
<tr><th>Type</th><th>Count</th></tr>
$for type, count in diffs:
<tr><td>$type</td><td>$count</td></tr>
</table>

View file

@ -6,10 +6,16 @@ $ active = model.get_active()
<html>
<head>
<meta name="viewport" content="width=device-width" />
<!--[if lt IE 9]>
<script src="static/themes/able-wpcom/html5.js" type="text/javascript"></script>
<![endif]-->
<title>$config.get('site', 'name')</title>
$ base = config.get('general', 'base')
$if base: <base href="$base"/>
<link rel="stylesheet" type="text/css" href="static/themes/able-wpcom/style.css">
<link rel="stylesheet" type="text/css" href="static/themes/common/niche.css">
$if features.rss:
<link rel="alternate" type="application/rss+xml" href="rss" title="RSS feed">
</head>
<body>
<div id="page" class="hfeed site">
@ -25,31 +31,58 @@ $ active = model.get_active()
<li><a href="user/$active.username">$active.username</a>
<li><a href="logout">Logout</a>
$else:
<li><a href="login">Login</a>
<li>
$if config.get('general', 'has_https'):
<a href="https://$config.get('general', 'domain')${base}login">Login</a>
$else:
<li><a href="login">Login</a>
<li><a href="link/new">New post</a>
<li><a href="links">Archive</a>
</ul>
</nav>
</header>
<div id="main">
$ message = model.get_message()
$if message:
<p class="message">$message</p>
<div id="primary" class="site-content">
$ message = model.get_message()
$if message:
<p class="message">$message</p>
<div id="content" role="main">
$:content
</div>
</div>
<div id="secondary">
$if active:
$ new = model.get_new()
$if new:
<div class="new">
<p>New comments on
<ul>
$for comment in new:
$ link = comment.link
<li><a href="link/$link.linkID#comment_$comment.commentID">
$if not link.URL: $link.title
$else: $:link.URL_description
</a>
</ul>
</div>
</div>
</div>
<hr/>
<footer id="colophon" class="site-footer" role="contentinfo">
<div class="site-info">
A <a href="https://juju.net.nz/src/niche.git/">niche</a> community filter
<span class="sep"> | </span>
Version $version
$if config.get('site', 'license'):
<span class="sep"> | </span>
$:config.get('site', 'license')
<span class="sep"> | </span>
<a href="https://github.com/nzmichaelh/niche/issues">Bugs</a>
<span class="sep"> | </span>
Theme based on <a href="http://abledemo.wordpress.com/">Able</a>
$if features.rss:
<span class="sep"> | </span>
<a type="application/rss+xml" href="rss"><img src="static/themes/common/feed-icon-14x14.png"/> RSS feed</a>
</div>
</footer>
</div>

View file

@ -3,16 +3,18 @@ $def with (link, form, preview)
$ here = "link/%s" % link.linkID
$ date = link.to_datestr()
<h2 class="entry-title">
<a href="daily/$link.to_date_link()">$date</a>
</h2>
<h1 class="entry-title">
<a href="links/$link.to_date_link()">$date</a>
</h1>
$if model.is_admin():
<p class="meta">
<a href="$here/hide">${ "Hidden" if link.hidden else "Showing" }</a> |
<a href="$here/hide">${ "Hidden" if link.hidden else "Showing" }</a>
<span class="sep"> | </span>
<a href="$here/close">${ "Closed" if link.closed else "Open for comments" }</a>
</p>
<div class="entry-summary">
$if not link.URL:
<b>$link.title</b>
$else:
@ -20,43 +22,46 @@ $else:
$:link.description
$if link.extended:
<p>$:link.extended
<p class="byline">
</div>
<div class="entry-meta">
$ user = link.user
posted by <a href="user/$user.username">$user.username</a>
$link.ago() ago
</div>
$ comments = link.comments
<ul class="commentlist">
$for comment in comments:
<li>
<div class="comment">
<div class="comment" id="comment_$comment.commentID">
$:comment.content
</b></i>
<footer>
$ user = comment.user
posted by <a href="user/$user.username">$user.username</a>
$link.ago() ago
posted <a href="$here#comment_$comment.commentID">$comment.ago()</a> ago
by <a href="user/$user.username">$user.username</a>
$if features.likes:
$ likes = comment.likes
| $len(likes) likes
| <a href="comment/$comment.commentID/like">I like it</a>
<span class="sep"> | </span>
$model.plural(comment.like_count, 'like')
<span class="sep"> | </span>
<a href="comment/$comment.commentID/like">I like it</a>
$if model.is_admin():
| <a href="comment/$comment.commentID/delete">Delete</a>
<span class="sep"> | </span>
<a href="comment/$comment.commentID/delete">Delete</a>
</footer>
</div>
</ul>
$if not link.closed:
$if form:
<a id="comment"></a>
$if preview != None:
<p class="preview">$:preview
<div id="commentform">
<form name="main" method="post">
$:form.render()
<input type="submit" name="post" value="Post"/>
<input type="submit" name="preview" value="Preview"/>
</form>
</div>
$else:
<p><a href="/link/$link.linkID/new#comment">New comment</a>
<a id="comment"></a>
$if preview:
<div class="preview">$:preview</div><p>
<div id="commentform">
<form name="main" method="post" action="$here/new">
$:form.render_css()
<br/>
<input type="submit" name="post" value="Post"/>
<input type="submit" name="preview" value="Preview"/>
</form>
</div>

View file

@ -1,4 +1,21 @@
$def with (links, span, base, offset, limit, total)
$def with (links, span, base, offset, limit, total, date_range)
$if date_range:
$for year in date_range.years:
$if not loop.first: <span class="sep"> | </span>
$if year == date_range.year: <b>
<a href="links/$year">$year</a>
$if year == date_range.year: </b>
<br/>
$if date_range.month is None: <b>
<a href="links/$date_range.year">Whole year</a>
$if date_range.month is None: </b>
$for i in range(1, len(date_range.months)):
<span class="sep"> | </span>
$if i == date_range.month: <b>
<a href="links/$date_range.year/$i">$date_range.months[i]</a>
$if i == date_range.month: </b>
<hr/>
$ last_date = None
@ -12,7 +29,7 @@ $for link in links:
<div class="entry-summary">
$if not link.URL:
$link.title
<b>$link.title</b>
$else:
<a href="$link.URL">$:link.URL_description</a>
$:link.description
@ -20,16 +37,17 @@ $for link in links:
<a href="link/$link.linkID" class="more-link">more inside</a>
</div>
<div class="entry-meta">
posted by <a href="user/$link.user.username">$link.user.username</a>
$link.ago() ago
posted $link.ago() ago
by <a href="user/$link.user.username">$link.user.username</a>
-
<a href="link/$link.linkID">$len(link.comments) comments</a>
<a href="link/$link.linkID">$model.plural(link.comment_count, 'comment')</a>
</div>
$if total > limit:
$ at, pages = model.paginate(offset, total, limit)
$if pages:
Page
$ at = 1 + offset // limit
$for i in range(0, total, limit):
$if loop.index == at: <b>
<a href="$base?offset=$i">$loop.index</a>
$if loop.index == at: </b>
$for page in pages:
$if page == at: <b>
<a href="$base?offset=${(page-1)*limit}">$page</a>
$if page == at: </b>

View file

@ -1,6 +1,15 @@
$def with (form)
<div id="commentform">
<form name="main" method="post">
$:form.render()
$:form.render_css()
<input type="submit"/>
</form>
</div>
$ contact = config.get('site', 'contact')
$if contact:
Forgotten your password?
<a href="mailto:$contact?subject=$config.get('site', 'name')%20password%20reset">
Send us an email.
</a>
<p/>

View file

@ -1,6 +0,0 @@
$def with (link, form)
<form name="main" method="post">
$:form.render()
<input type="submit"/>
</form>

View file

@ -13,8 +13,12 @@ $if preview != None:
</div>
<p>
<div id="commentform">
<form name="main" method="post">
$:form.render()
$:form.render_css()
<br/>
<input type="submit" name="post" value="Post"/>
<input type="submit" name="preview" value="Preview"/>
</form>
</div>

View file

@ -1,8 +1,12 @@
$def with (form)
<div id="commentform">
<form name="main" method="post">
$if not form.valid:
<p class="error">Something's wrong</p>
$:form.render()
$:form.render_css()
<br/>
<input type="submit"/>
</form>
</div>

32
templates/rss.html Normal file
View file

@ -0,0 +1,32 @@
$def with (links, base)
<!--?xml version="1.0"?-->
<rss version="2.0">
<channel>
<title>$config.get('site', 'name')</title>
<link>$base</link>
$ subtitle = config.get('site', 'subtitle')
$if subtitle: <description>$subtitle</description>
<language>en-us</language>
<generator>niche</generator>
$ contact = config.get('site', 'contact')
$if contact: <managingEditor>$contact</managingEditor>
$if contact: <webMaster>$contact</webMaster>
$for link in links:
<item>
<title>
$if not link.URL: $link.title
$else: $link.URL_description
</title>
<description>
$if link.URL:
<![CDATA[<a href="$link.URL">$:link.URL_description</a>]]>
<![CDATA[$:link.description]]>
</description>
<author>$link.user.username</author>
<pubDate>$model.to_rss_date(link.timestamp)</pubDate>
<link>$base/link/$link.linkID</link>
<guid>$base/link/$link.linkID</guid>
</item>
</channel>
</rss>

View file

@ -1,20 +1,34 @@
$def with (user)
<h2>$user.username's profile</h2>
<p>
$if features.gravatar:
<img src="http://www.gravatar.com/avatar/$model.get_gravatar(user.email)"/>
Name: $user.realname
<p>User ID: $user.userID
$if model.get_active() and user.userID == model.get_active().userID:
$ fields = user.contacts_json
<h2>
$if fields.gravatar_email:
<img src="http://www.gravatar.com/avatar/$model.get_gravatar(fields.gravatar_email)"/>
$user.username (user #$user.userID)</h2>
$if model.get_active():
<ul>
$for field in config.getlist('general', 'user_fields'):
$ value = fields.get(field)
$ value = value if value else user.get(field)
$if value:
<li>$model.field_text(field): $:linkify(value)
</ul>
$if model.is_user_or_admin(user.userID):
<a href="user/$user.username/edit">Edit</a>
<span class="sep"> | </span>
<a href="user/$user.username/password">Set password</a>
<p>Homepage: <a href="$user.homepage">$user.homepage</a>
<p>Bio:
$:user.bio
$else:
<a href="login">Log in</a> for details.
<p><hr/>
$:render_input(user.bio)
<p><hr/>
<p>
<a href="user/$user.username/links">$len(user.links) links</a>
| <a href="user/$user.username/comments">$len(user.comments) comments</a>
<a href="user/$user.username/links">$model.plural(user.link_count, 'link')</a>
<span class="sep"> | </span>
<a href="user/$user.username/comments">$model.plural(user.comment_count, 'comment')</a>
$if model.is_user_or_admin(user.userID):
<span class="sep"> | </span>
<a href="user/$user.username/checkout">All links as RSS</a>
$if features.likes:
| <a href="user/$user.username/likes">$len(user.likes) likes</a>
<span class="sep"> | </span>
<a href="user/$user.username/likes">$model.plural(user.like_count, 'like')</a>

12
templates/user_edit.html Normal file
View file

@ -0,0 +1,12 @@
$def with (user, form)
<h2>Editing $user.username</h2>
<div id="commentform">
<form name="main" method="post">
$:form.render_css()
<br/>
<input type="submit" name="post" value="Update"/>
</form>
</div>
<p>Your bio is shown to all visitors. Other details are only shown to logged in users.

15
tests.mk Normal file
View file

@ -0,0 +1,15 @@
GET = / \
/links /links/2010 /links/2010/12 \
/link/2 /link/100 \
/user/michaelh /user/tracicle \
/login \
/rss /rss.php /rss.xml
BASE = http://ch.monkeyfilter.com
ALL = $(GET:%=%-get)
all: $(ALL)
%-get:
wget -O /dev/null -nv $(BASE)$*

1
update.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE 1_users ADD contacts text;