A community web log for monkey niches.

niche.py 31KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145
  1. #!/usr/bin/python
  2. import datetime
  3. import hashlib
  4. import ConfigParser
  5. import passlib
  6. import markdown
  7. import re
  8. import time
  9. import calendar
  10. import json
  11. import random
  12. import sys
  13. import pickle
  14. import urllib
  15. import memcache
  16. import web
  17. import bleach
  18. from passlib.apps import custom_app_context as pwd_context
  19. import strings
  20. import utils
  21. import version
  22. web.config.debug = False
  23. # pylint: disable=redefined-builtin
  24. # pylint: disable=redefined-outer-name
  25. # pylint: disable=no-init
  26. urls = (
  27. r'/?', 'index',
  28. r'/links(/\d+)?(/\d+)?(/\d+)?', 'links',
  29. r'/link/new', 'new_link',
  30. r'/link/(\d+)', 'link',
  31. r'/link/(\d+)/hide', 'hide_link',
  32. r'/link/(\d+)/close', 'close_link',
  33. r'/link/(\d+)/new', 'new_comment',
  34. r'/comment/(\d+)/delete', 'delete_comment',
  35. r'/comment/(\d+)/like', 'like_comment',
  36. r'/user/([^/]+)', 'user',
  37. r'/user/([^/]+)/links', 'user_links',
  38. r'/user/([^/]+)/comments', 'user_comments',
  39. r'/user/([^/]+)/checkout', 'checkout',
  40. r'/user/([^/]+)/password', 'password',
  41. r'/user/([^/]+)/edit', 'user_edit',
  42. r'/login', 'login',
  43. r'/logout', 'logout',
  44. r'/rss', 'rss',
  45. r'/debug/counters', 'debug_counters',
  46. r'/debug/diediedie', 'debug_die',
  47. # MonkeyFilter compatible URLs.
  48. r'/link\.php/(\d+)', 'link',
  49. r'/user\.php/([^/]+)', 'user',
  50. r'/rss.php', 'rss',
  51. r'/rss.xml', 'rss',
  52. )
  53. ALLOWED_TAGS = """
  54. a abbr acronym b blockquote br
  55. code em i ol ul li p
  56. pre quote small strike strong
  57. u img
  58. """.replace('\n', ' ').strip().split()
  59. ALLOWED_ATTRIBUTES = {
  60. 'a': ['href', 'title'],
  61. 'abbr': ['title'],
  62. 'acronym': ['title'],
  63. 'img': ['src', 'alt'],
  64. }
  65. # Default configuration.
  66. DEFAULTS = [
  67. ('general', {
  68. 'dateformat': '%B %d, %Y',
  69. 'base': '/',
  70. 'has_https': False,
  71. 'extra_tags': '',
  72. 'limit': 20,
  73. 'server_type': 'dev',
  74. 'user_fields': ('realname email homepage gravatar_email '
  75. 'team location twitter facebook '
  76. 'google_plus_ skype aim'),
  77. 'history_days': 7,
  78. }),
  79. ('groups', {
  80. 'admins': '',
  81. }),
  82. ('db', {
  83. 'db': 'niche',
  84. 'user': 'niche',
  85. 'password': 'whatever',
  86. }),
  87. ('cache', {
  88. 'host': 'localhost:11211',
  89. 'max_age': 15,
  90. }),
  91. ('site', {
  92. 'name': 'Nichefilter',
  93. 'subtitle': 'of no fixed subtitle',
  94. 'contact': None,
  95. 'license': None,
  96. 'secret': '',
  97. }),
  98. ]
  99. counters = utils.Counters()
  100. class Config(ConfigParser.RawConfigParser):
  101. def set_defaults(self, defaults):
  102. for section, items in defaults:
  103. self.add_section(section)
  104. for name, value in items.items():
  105. self.set(section, name, value)
  106. def getlist(self, section, option):
  107. return self.get(section, option).split()
  108. def read_config():
  109. """Set up the defaults and read in niche.ini, if any."""
  110. cfg = Config()
  111. cfg.set_defaults(DEFAULTS)
  112. cfg.read('niche.ini')
  113. return cfg
  114. config = read_config()
  115. FEATURES = 'likes gravatar rss checkout'.split()
  116. def get_features(config):
  117. features = web.utils.Storage()
  118. for feature in FEATURES:
  119. features[feature] = False
  120. if config.has_option('features', feature):
  121. features[feature] = config.getboolean('features', feature)
  122. return features
  123. features = get_features(config)
  124. def get_version():
  125. return version.__version__
  126. def get_string(id):
  127. """Get a string gettext style. Splits the strings from the
  128. code.
  129. """
  130. id = id.lower()
  131. id = re.sub(r'[\']', '', id)
  132. id = re.sub(r'\W', '_', id)
  133. id = re.sub(r'_{2,}', '_', id)
  134. id = re.sub(r'_$', '', id)
  135. return strings.__dict__[id]
  136. _ = get_string
  137. db = web.database(dbn='mysql',
  138. user=config.get('db', 'user'),
  139. pw=config.get('db', 'password'),
  140. db=config.get('db', 'db'),
  141. )
  142. class DBCache:
  143. def __init__(self, db, host, max_age=60, prefix=None):
  144. self._db = db
  145. self._cache = memcache.Client([host],
  146. pickleProtocol=pickle.HIGHEST_PROTOCOL)
  147. self._max_age = max_age
  148. self._prefix = prefix
  149. def make_dirty_key(self, table):
  150. return '/'.join((self._prefix, table))
  151. def make_key(self, table, column, value, limit):
  152. return '/'.join((
  153. self._prefix,
  154. table,
  155. column,
  156. hashlib.md5(str(value)).hexdigest(),
  157. str(limit)
  158. ))
  159. def select(self, table, column, value, limit=None):
  160. dirty = self.make_dirty_key(table)
  161. elem = self.make_key(table, column, value, limit)
  162. got = self._cache.get_multi((dirty, elem))
  163. if dirty not in got and elem in got and self._max_age:
  164. counters.bump('select_cache_hit')
  165. return got[elem]
  166. else:
  167. rows = list(self._db.select(table, where='%s = $value' % column,
  168. vars={'value': value}, limit=limit))
  169. counters.bump('select_cache_miss')
  170. self._cache.set(elem, rows, time=self._max_age)
  171. return rows
  172. def update(self, type, id, **kwargs):
  173. table = '1_%ss' % type
  174. column = '%sID' % type
  175. dirty = self.make_dirty_key(table)
  176. self._db.update(table,
  177. where='%s = $id' % column,
  178. vars={'id': id}, **kwargs)
  179. self._cache.set(dirty, 1)
  180. def insert(self, type, **kwargs):
  181. table = '1_%ss' % type
  182. dirty = self.make_dirty_key(table)
  183. result = self._db.insert(table, **kwargs)
  184. self._cache.set(dirty, 1)
  185. return result
  186. cache = DBCache(db,
  187. host=config.get('cache', 'host'),
  188. max_age=config.get('cache', 'max_age'),
  189. prefix=config.get('db', 'db'))
  190. def require_feature(name):
  191. if not features[name]:
  192. raise web.notfound()
  193. def now():
  194. return time.time()
  195. fallbacks = {
  196. 'user': web.utils.Storage(username='anonymous'),
  197. }
  198. class JSONMapper:
  199. def __init__(self, around, name):
  200. self._around = around
  201. self._name = name
  202. raw = getattr(around, name)
  203. raw = json.loads(raw) if raw else {}
  204. self._values = web.storage(raw)
  205. def __getattr__(self, key):
  206. return getattr(self._values, key, None)
  207. def get(self, key):
  208. return getattr(self._values, key, None)
  209. def set(self, key, value):
  210. if value != getattr(self, key):
  211. self._values[key] = value
  212. encoded = json.dumps(self._values)
  213. cache.update('user', self._around.userID, contacts=encoded)
  214. class AutoMapper:
  215. def __init__(self, type, around):
  216. self._type = type
  217. self._around = around
  218. def __getattr__(self, name):
  219. if hasattr(self._around, name):
  220. return getattr(self._around, name)
  221. key = '%sID' % name
  222. if hasattr(self._around, key):
  223. id = getattr(self._around, key)
  224. got = first_or_none(name, key, id)
  225. if got:
  226. return got
  227. if name in fallbacks:
  228. return fallbacks[name]
  229. raise web.notfound()
  230. if name.endswith('_json'):
  231. field = name[:-5]
  232. mapper = JSONMapper(self, field)
  233. setattr(self, name, mapper)
  234. return mapper
  235. if name.endswith('_count'):
  236. field = name[:-6]
  237. query = ('SELECT COUNT(*) AS total FROM 1_%ss '
  238. 'WHERE %sID = $id') % (field, self._type)
  239. results = db.query(
  240. query,
  241. vars={'id': getattr(self, '%sID' % self._type)})
  242. return results[0].total
  243. if name.endswith('s'):
  244. singular = name[:-1]
  245. table = '1_%s' % name
  246. assert self._type
  247. key = '%sID' % self._type
  248. rows = cache.select(table, key, getattr(self, key))
  249. return [AutoMapper(singular, x) for x in rows]
  250. raise AttributeError(name)
  251. def get(self, key):
  252. return getattr(self, key, None)
  253. def has(self, key):
  254. return key in self._around
  255. def ago(self):
  256. return utils.ago(self.timestamp)
  257. def to_date(self):
  258. """Convert a timestamp to a date object."""
  259. return datetime.date.fromtimestamp(self.timestamp)
  260. def to_datestr(self):
  261. """Convert a timestamp to a date string."""
  262. return self.to_date().strftime(config.get('general', 'dateformat'))
  263. def to_date_link(self):
  264. """Convert a timestamp to a link."""
  265. date = self.to_date()
  266. return '%04d/%02d/%02d' % (date.year, date.month, date.day)
  267. def map_all(type, results):
  268. return [AutoMapper(type, x) for x in results]
  269. def first_or_none(type, column, id, strict=False):
  270. """Get the first item in the table that matches or None if there's
  271. no match.
  272. """
  273. table = '1_%ss' % type
  274. vs = cache.select(table, column, id, limit=1)
  275. if len(vs):
  276. return AutoMapper(type, vs[0])
  277. elif strict:
  278. raise web.notfound()
  279. else:
  280. return None
  281. def first(type, column, id):
  282. """Get the first matching item in the table or raise not found."""
  283. return first_or_none(type, column, id, strict=True)
  284. def linkify(text):
  285. return bleach.clean(bleach.linkify(text, parse_email=True))
  286. def render_input(v, use_markdown=False):
  287. """Tidy up user input and insert breaks for empty lines."""
  288. tags = ALLOWED_TAGS + config.getlist('general', 'extra_tags')
  289. attrs = ALLOWED_ATTRIBUTES
  290. # Drop any trailing empty lines.
  291. v = v.rstrip()
  292. if not v:
  293. return v
  294. if use_markdown:
  295. return bleach.clean(
  296. markdown.markdown(v, output_format='html5'),
  297. tags=tags, attributes=attrs)
  298. else:
  299. v = bleach.clean(v, tags=tags, attributes=attrs)
  300. out = ''
  301. for line in v.split('\n'):
  302. if not line.strip():
  303. out += '<br/>\n'
  304. else:
  305. out += line + '\n'
  306. return out
  307. class Model:
  308. """Top level helpers. Exposed to scripts."""
  309. def is_admin(self):
  310. id = session.get('userID', None)
  311. admins = config.getlist('groups', 'admins')
  312. return id is not None and (str(id) in admins)
  313. def is_user_or_admin(self, user_id):
  314. id = session.get('userID', None)
  315. if id is None:
  316. return False
  317. elif id == user_id:
  318. return True
  319. else:
  320. return self.is_admin()
  321. def get_link(self, id):
  322. """Get a link by link ID"""
  323. return first('link', 'linkID', id)
  324. def get_comment(self, id):
  325. """Get a comment by comment ID"""
  326. return first_or_none('comment', 'commentID', id, strict=True)
  327. def get_user(self, id):
  328. """Get a user by user ID"""
  329. return first_or_none('user', 'userID', id)
  330. def get_user_by_name(self, name):
  331. """Get a user by user name"""
  332. name = urllib.unquote_plus(name)
  333. user = first('user', 'username', name)
  334. return first('user', 'userID', user.userID)
  335. def get_gravatar(self, email):
  336. """Get the gravatar hash for an email"""
  337. return hashlib.md5(email.strip().lower()).hexdigest()
  338. def get_message(self):
  339. """Get the message for the user, if any, and clear"""
  340. message = session.get('message', None)
  341. if message:
  342. session.message = None
  343. return message
  344. def inform(self, message):
  345. """Log a message to show the user on the next page"""
  346. session.message = message
  347. def get_active(self):
  348. """Get the user entry for the currently logged in user, or
  349. None.
  350. """
  351. id = session.get('userID', None)
  352. if not id:
  353. return None
  354. return first_or_none('user', 'userID', id)
  355. def is_active(self):
  356. return session.get('userID', None) is not None
  357. def paginate(self, offset, total, per_page):
  358. # TODO(michaelh): really a helper, not part of the model.
  359. page = 1 + offset // per_page
  360. pages = total // per_page
  361. step = 1
  362. for step in (1, 2, 5, 10, 20, 50):
  363. if pages / step <= 6:
  364. break
  365. if pages > 1:
  366. indexes = set(
  367. [1, 2, pages, pages-1, page]
  368. + range(step, pages, step))
  369. if page > 1:
  370. indexes.add(page-1)
  371. if page < pages:
  372. indexes.add(page+1)
  373. else:
  374. indexes = []
  375. return page, sorted(indexes)
  376. def to_rss_date(self, timestamp):
  377. fmt = '%a, %d %b %Y %H:%M:%S +0000'
  378. asdate = datetime.datetime.fromtimestamp(timestamp)
  379. return asdate.strftime(fmt)
  380. def field_text(self, name):
  381. return get_string('field_%s' % name)
  382. def plural(self, value, name):
  383. # I appoligise in advance.
  384. if value == 1:
  385. return '%s %s' % (value, name)
  386. else:
  387. return '%d %ss' % (value, name)
  388. def get_new(self):
  389. since = now() - 60*60*24*config.get('general', 'history_days')
  390. comments = db.select(
  391. '1_comments',
  392. where='timestamp >= $since AND userID <> $user',
  393. order='timestamp ASC', limit=50,
  394. vars={'since': since,
  395. 'user': session.get('userID', None)})
  396. # Pull out the unique links.
  397. ids = {}
  398. for comment in comments:
  399. if comment.linkID not in ids:
  400. ids[comment.linkID] = comment
  401. comments = sorted(ids.values(), key=lambda x: x.linkID)
  402. return [AutoMapper('comment', x) for x in comments]
  403. model = Model()
  404. render_globals = {
  405. 'model': model,
  406. 'config': config,
  407. 'features': features,
  408. 'version': get_version(),
  409. 'linkify': linkify,
  410. 'render_input': render_input,
  411. }
  412. render = web.template.render(
  413. 'templates/',
  414. base='layout',
  415. globals=render_globals,
  416. )
  417. naked_render = web.template.render(
  418. 'templates/',
  419. globals=render_globals,
  420. )
  421. app = web.application(urls, locals())
  422. def get_csrf():
  423. token = session.get('csrf_token', None)
  424. if token is None:
  425. token = hashlib.md5(''.join((
  426. str(random.randrange(0, 2**20)),
  427. config.get('site', 'secret'),
  428. config.get('db', 'db'),
  429. config.get('db', 'user')))
  430. ).hexdigest()
  431. session.csrf_token = token
  432. return token
  433. def check_csrf(value):
  434. expect = session.get('csrf_token', None)
  435. if value is None or value != expect:
  436. model.inform(_('Possible cross site request forgery. Try again.'))
  437. return False
  438. else:
  439. return True
  440. class CSRFInput(web.form.Hidden):
  441. def __init__(self):
  442. super(CSRFInput, self).__init__(name='csrf_token')
  443. def render(self):
  444. attrs = self.attrs.copy()
  445. attrs['type'] = self.get_type()
  446. attrs['value'] = get_csrf()
  447. attrs['name'] = self.name
  448. return '<input %s/>' % attrs
  449. def validate(self, value):
  450. return check_csrf(value)
  451. TEXT_SIZE = 80
  452. TEXT_MAX_LENGTH = 150
  453. def tidy_form(form):
  454. for input in form.inputs:
  455. if not isinstance(input, CSRFInput):
  456. input.description = get_string('field_%s' % input.name)
  457. if isinstance(input, (web.form.Textbox, web.form.Password)):
  458. input.attrs['size'] = TEXT_SIZE
  459. input.attrs['maxlength'] = TEXT_MAX_LENGTH
  460. return form
  461. def make_session():
  462. """Helper that makes the session object, even if in debug mode."""
  463. if web.config.get('_session') is None:
  464. session = web.session.Session(app, web.session.DiskStore('sessions'),
  465. initializer={'message': None}
  466. )
  467. web.config._session = session
  468. else:
  469. session = web.config._session
  470. return session
  471. session = make_session()
  472. # Validate a password. Pretty lax.
  473. password_validator = web.form.Validator(
  474. _("Short password"),
  475. lambda x: len(x) >= 3)
  476. def url_validator(v):
  477. if not v:
  478. return True
  479. return re.match('(http|https|ftp|mailto)://.+', v)
  480. def redirect(url):
  481. """Bounce to a different site absolute URL."""
  482. has_https = config.get('general', 'has_https')
  483. if has_https and model.is_active():
  484. base = config.get('general', 'base')
  485. if base.endswith('/') and url.startswith('/'):
  486. path = base[:-1] + url
  487. else:
  488. path = base + url
  489. raise web.seeother('https://{host}{path}'.format(host=web.ctx.host, path=path), absolute=True)
  490. else:
  491. raise web.seeother(url)
  492. def authenticate(msg=_("Login required")):
  493. if not session.get('userID', None):
  494. model.inform(msg)
  495. redirect('/login')
  496. def need_admin(msg):
  497. if not model.is_admin():
  498. model.inform(msg)
  499. redirect('/login')
  500. def need_user_or_admin(id, msg):
  501. if not model.is_user_or_admin(id):
  502. model.inform(msg)
  503. redirect('/login')
  504. def check_password(got, user):
  505. if user is None:
  506. return False
  507. try:
  508. return passlib.hash.mysql323.verify(got, user.password)
  509. except ValueError:
  510. return pwd_context.verify(got, user.password)
  511. def error(message, condition, target='/'):
  512. """Log an error if condition is true and bounce to somewhere."""
  513. if condition:
  514. counters.bump('error')
  515. model.inform(message)
  516. redirect(target)
  517. def render_links(where=None, span=None, vars=None, date_range=None):
  518. vars = vars or {}
  519. input = web.input()
  520. offset = int(input.get('offset', 0))
  521. offset = max(offset, 0)
  522. limit = int(input.get('limit', config.get('general', 'limit')))
  523. limit = max(0, min(200, limit))
  524. links = db.select('1_links', where=where, vars=vars,
  525. limit=limit, offset=offset,
  526. order="timestamp DESC")
  527. if where:
  528. results = db.query(
  529. 'SELECT COUNT(*) AS total FROM 1_links WHERE %s' % where,
  530. vars=vars)
  531. else:
  532. results = db.query("SELECT COUNT(*) AS total FROM 1_links")
  533. total = results[0].total
  534. return render.links(map_all('link', links), span,
  535. web.ctx.path, offset, limit,
  536. total, date_range)
  537. class index:
  538. def GET(self):
  539. counters.bump(self)
  540. return render_links()
  541. class links:
  542. def GET(self, year, month, day):
  543. counters.bump(self)
  544. def tidy(v, low, high):
  545. """Turn an optional parameter into a validated number"""
  546. if v:
  547. v = int(v[1:])
  548. if v < low or v > high:
  549. raise web.notfound()
  550. return False, v
  551. else:
  552. return True, low
  553. no_year, year = tidy(year, 1990, 2037)
  554. no_month, month = tidy(month, 1, 12)
  555. no_day, day = tidy(day, 1, 31)
  556. start = datetime.date(year, month, day)
  557. span = None
  558. # Figure out the span based on what was supplied
  559. if no_year:
  560. # Work around the year 2038 problem.
  561. end_year = min(2037, year + 100)
  562. end = datetime.date(end_year, month, day)
  563. span = 'years'
  564. elif no_month:
  565. end = datetime.date(year + 1, month, day)
  566. span = 'months'
  567. elif no_day:
  568. if month == 12:
  569. end = datetime.date(year + 1, 1, day)
  570. else:
  571. end = datetime.date(year, month + 1, day)
  572. else:
  573. end = start + datetime.timedelta(days=1)
  574. tstart = time.mktime(start.timetuple())
  575. tend = time.mktime(end.timetuple())
  576. # Pull the oldest and youngest from the database.
  577. limits = db.query(('SELECT MIN(timestamp) as first, MAX(timestamp) '
  578. 'as last FROM 1_links'))
  579. limits = limits[0]
  580. first = datetime.datetime.fromtimestamp(limits.first)
  581. last = datetime.datetime.fromtimestamp(limits.last)
  582. date_range = web.utils.Storage(
  583. years=range(first.year, last.year+1),
  584. year=None if no_year else year,
  585. month=None if no_month else month,
  586. months=calendar.month_name,
  587. )
  588. return render_links(
  589. where='timestamp >= $tstart and timestamp < $tend',
  590. vars={'tstart': tstart, 'tend': tend},
  591. span=span,
  592. date_range=date_range,
  593. )
  594. class link:
  595. def GET(self, id):
  596. counters.bump(self)
  597. link = model.get_link(id)
  598. form = new_comment.form()
  599. return render.link(link, form, False)
  600. class new_link:
  601. form = web.form.Form(
  602. web.form.Textbox('title', web.form.notnull),
  603. web.form.Textbox('url', web.form.Validator(
  604. _("Not a URL"), url_validator)),
  605. web.form.Textbox('url_description'),
  606. web.form.Textarea('description', rows=5, cols=80),
  607. web.form.Textarea('extended', rows=5, cols=80),
  608. web.form.Checkbox('use_markdown', value='use_markdown'),
  609. CSRFInput(),
  610. validators=[
  611. web.form.Validator(
  612. _("URLs need a description"),
  613. lambda x: x.url_description if x.url else True),
  614. web.form.Validator(
  615. _("Need a URL or description"),
  616. lambda x: x.url or x.description),
  617. ]
  618. )
  619. form = tidy_form(form)
  620. def authenticate(self):
  621. authenticate(_("Login to post"))
  622. def GET(self):
  623. self.authenticate()
  624. return render.new_link(self.form(), None)
  625. def POST(self):
  626. counters.bump(self)
  627. self.authenticate()
  628. form = self.form()
  629. if not form.validates():
  630. return render.new_link(form, None)
  631. user = model.get_active()
  632. markdown = form.d.use_markdown
  633. url_description = render_input(form.d.url_description, markdown)
  634. description = render_input(form.d.description, markdown)
  635. extended = render_input(form.d.extended, markdown)
  636. if 'preview' in web.input():
  637. preview = web.utils.Storage(
  638. title=form.d.title,
  639. URL=form.d.url,
  640. URL_description=url_description,
  641. description=description,
  642. extended=extended)
  643. return render.new_link(form, preview)
  644. next = cache.insert('link',
  645. userID=user.userID,
  646. timestamp=now(),
  647. title=form.d.title,
  648. URL=form.d.url,
  649. URL_description=url_description,
  650. description=description,
  651. extended=extended
  652. )
  653. model.inform(_("New post success"))
  654. redirect('/link/%d' % next)
  655. class hide_link:
  656. def GET(self, id):
  657. counters.bump(self)
  658. link = model.get_link(id)
  659. need_admin(_('Admin needed to hide a link'))
  660. next = not link.hidden
  661. cache.update('link', id, hidden=next)
  662. model.inform(_("Link is hidden") if next else _("Link now shows"))
  663. redirect('/link/%s' % id)
  664. class close_link:
  665. def GET(self, id):
  666. counters.bump(self)
  667. link = model.get_link(id)
  668. need_admin(_('Admin needed to close a link'))
  669. next = not link.closed
  670. cache.update('link', id, closed=next)
  671. model.inform(_("Link is closed") if next else _("Link is open"))
  672. redirect('/link/%s' % id)
  673. class new_comment:
  674. form = web.form.Form(
  675. web.form.Textarea('comment', web.form.notnull, rows=5, cols=80),
  676. web.form.Checkbox('use_markdown', value='use_markdown'),
  677. CSRFInput(),
  678. )
  679. form = tidy_form(form)
  680. def check(self, id):
  681. authenticate(_("Login to comment"))
  682. link = model.get_link(id)
  683. error(_("Link is closed"), link.closed)
  684. return link
  685. def POST(self, id):
  686. counters.bump(self)
  687. link = self.check(id)
  688. form = self.form()
  689. if not form.validates():
  690. return render.link(link, form, None)
  691. user = model.get_active()
  692. comment = render_input(form.d.comment, form.d.use_markdown)
  693. if 'preview' in web.input():
  694. return render.link(link, form, comment)
  695. cache.insert('comment',
  696. linkID=link.linkID,
  697. userID=user.userID,
  698. timestamp=now(),
  699. content=comment
  700. )
  701. model.inform(_("New comment success"))
  702. redirect('/link/%d' % link.linkID)
  703. class delete_comment:
  704. def GET(self, id):
  705. counters.bump(self)
  706. comment = model.get_comment(id)
  707. need_admin(_('Admin needed to delete a comment'))
  708. db.delete('1_comments', where='commentID = $id', vars={'id': id})
  709. model.inform(_("Comment deleted"))
  710. redirect('/link/%s' % comment.linkID)
  711. class like_comment:
  712. def GET(self, id):
  713. counters.bump(self)
  714. # TODO: CSRF.
  715. require_feature('likes')
  716. authenticate(_("Login to like"))
  717. comment = model.get_comment(id)
  718. userID = session.userID
  719. cache.insert('like', commentID=comment.commentID, userID=userID)
  720. model.inform(_("Liked"))
  721. redirect('/link/%s' % comment.linkID)
  722. class user:
  723. def GET(self, id):
  724. counters.bump(self)
  725. target = model.get_user_by_name(id)
  726. return render.user(target)
  727. class user_links:
  728. def GET(self, id):
  729. counters.bump(self)
  730. target = model.get_user_by_name(id)
  731. return render_links(where='userID=$id', vars={'id': target.userid})
  732. class user_comments:
  733. def GET(self, id):
  734. counters.bump(self)
  735. userid = first('user', 'username', id).userID
  736. comments = db.select(
  737. '1_comments', where='userID=$id',
  738. order='timestamp DESC', vars={'id': userid},
  739. limit=config.get('general', 'limit'))
  740. return render.user_comments(
  741. [AutoMapper('comment', x) for x in comments])
  742. class checkout:
  743. def GET(self, name):
  744. counters.bump(self)
  745. require_feature('checkout')
  746. user = model.get_user_by_name(name)
  747. need_user_or_admin(user.userID,
  748. _('Only the user can checkout their links'))
  749. web.header('Content-Type', 'application/xml')
  750. return naked_render.rss(user.links, web.ctx.home)
  751. class login:
  752. login = web.form.Form(
  753. web.form.Textbox('username', web.form.notnull),
  754. web.form.Password('password', web.form.notnull),
  755. CSRFInput(),
  756. )
  757. login = tidy_form(login)
  758. def GET(self):
  759. return render.login(self.login())
  760. def POST(self):
  761. counters.bump(self)
  762. form = self.login()
  763. if not form.validates():
  764. return render.login(form)
  765. user = first_or_none('user', 'username', form.d.username)
  766. if not check_password(form.d.password, user):
  767. counters.bump(self, 'fail')
  768. form.valid = False
  769. model.inform(_("Bad username or password"))
  770. return render.login(form)
  771. session.userID = user.userID
  772. model.inform(_("Logged in"))
  773. counters.bump(self, 'ok')
  774. redirect('/')
  775. class logout:
  776. def GET(self):
  777. counters.bump(self)
  778. session.userID = None
  779. model.inform(_("Logged out"))
  780. redirect('/')
  781. class password:
  782. form = web.form.Form(
  783. web.form.Password('password', web.form.notnull),
  784. web.form.Password('new_password',
  785. web.form.notnull, password_validator),
  786. web.form.Password('again', web.form.notnull),
  787. CSRFInput(),
  788. validators=[
  789. web.form.Validator(
  790. _("Passwords don't match"),
  791. lambda x: x.new_password == x.again)
  792. ]
  793. )
  794. form = tidy_form(form)
  795. def authenticate(self, name):
  796. authenticate()
  797. target = model.get_user_by_name(name)
  798. need_user_or_admin(target.userID, _('Permission denied'))
  799. return target
  800. def GET(self, name):
  801. self.authenticate(name)
  802. return render.password(self.form())
  803. def POST(self, name):
  804. counters.bump(self)
  805. target = self.authenticate(name)
  806. form = self.form()
  807. if not form.validates():
  808. return render.password(form)
  809. if not model.is_admin():
  810. if not check_password(form.d.password, target):
  811. counters.bump(self, 'bad_password')
  812. form.note = _('Bad password')
  813. return render.password(form)
  814. cache.update('user', target.userID,
  815. password=pwd_context.encrypt(form.d.new_password))
  816. model.inform(_("Password changed"))
  817. redirect('/user/%s' % name)
  818. class user_edit:
  819. def make_form(self, user):
  820. names = config.getlist('general', 'user_fields')
  821. values = user.contacts_json
  822. def get(name):
  823. value = values.get(name)
  824. return value if value else user.get(name)
  825. fields = [web.form.Textbox(x, value=get(x), size=60) for x in names]
  826. fields.append(web.form.Textarea('bio',
  827. rows=5, cols=80, value=get('bio')))
  828. fields.append(CSRFInput())
  829. return tidy_form(web.form.Form(*fields))
  830. def get_target(self, name):
  831. authenticate()
  832. target = model.get_user_by_name(name)
  833. need_user_or_admin(target.userID, _('Permission denied'))
  834. return target
  835. def GET(self, name):
  836. target = self.get_target(name)
  837. form = self.make_form(target)
  838. return render.user_edit(target, form)
  839. def POST(self, username):
  840. counters.bump(self)
  841. target = self.get_target(username)
  842. form = self.make_form(target)
  843. if not form.validates():
  844. return render.user_edit(target, form)
  845. names = config.getlist('general', 'user_fields')
  846. values = target.contacts_json
  847. for name in names:
  848. if target.has(name):
  849. cache.update('user', target.userID, **{name: form[name].value})
  850. else:
  851. values.set(name, form[name].value)
  852. bio = render_input(form.d.bio)
  853. cache.update('user', target.userID, bio=bio)
  854. redirect('/user/%s' % username)
  855. class rss:
  856. def GET(self):
  857. counters.bump(self)
  858. require_feature('rss')
  859. links = db.select('1_links', order='linkID DESC', limit=20)
  860. links = map_all('link', links)
  861. web.header('Content-Type', 'application/xml')
  862. return naked_render.rss(links, web.ctx.home)
  863. class debug_counters:
  864. def GET(self):
  865. counters.bump(self)
  866. need_admin(_('Only admins can access debug pages.'))
  867. web.header('Content-Type', 'application/json')
  868. return json.dumps(counters.get_snapshot())
  869. class debug_die:
  870. def GET(self):
  871. need_admin(_('Only admins can access debug pages.'))
  872. sys.exit(0)
  873. def main():
  874. server_type = config.get('general', 'server_type')
  875. if server_type == 'fastcgi':
  876. web.wsgi.runwsgi = lambda func, addr=None: web.wsgi.runfcgi(func, addr)
  877. elif server_type == 'dev':
  878. # Development machine. Run stand alone
  879. pass
  880. else:
  881. raise ValueError('Unhandled server_type "%s"' % server_type)
  882. app.run()
  883. if __name__ == "__main__":
  884. main()