Personal wiki notebook (not under development)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Root.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. import cherrypy
  2. from Expose import expose
  3. from Expire import strongly_expire
  4. from Validate import validate, Valid_int, Valid_string, Valid_bool
  5. from Notebooks import Notebooks
  6. from Users import Users, grab_user_id
  7. from Groups import Groups
  8. from Files import Files
  9. from Forums import Forums, Forum
  10. from Database import Valid_id, end_transaction
  11. from Users import update_auth
  12. from model.Note import Note
  13. from model.Notebook import Notebook
  14. from model.User import User
  15. from view.Main_page import Main_page
  16. from view.Front_page import Front_page
  17. from view.Tour_page import Tour_page
  18. from view.Upgrade_page import Upgrade_page
  19. from view.Download_page import Download_page
  20. from view.Forums_page import Forums_page
  21. from view.Notebook_rss import Notebook_rss
  22. from view.Json import Json
  23. from view.Error_page import Error_page
  24. from view.Not_found_page import Not_found_page
  25. from view.Close_page import Close_page
  26. class Root( object ):
  27. """
  28. The root of the controller hierarchy, corresponding to the "/" URL.
  29. """
  30. def __init__( self, database, settings, suppress_exceptions = False ):
  31. """
  32. Create a new Root object with the given settings.
  33. @type database: controller.Database
  34. @param database: database to use for all controllers
  35. @type settings: dict
  36. @param settings: CherryPy-style settings with top-level "global" key
  37. @rtype: Root
  38. @return: newly constructed Root
  39. """
  40. self.__database = database
  41. self.__settings = settings
  42. self.__users = Users(
  43. database,
  44. settings[ u"global" ].get( u"luminotes.http_url", u"" ),
  45. settings[ u"global" ].get( u"luminotes.https_url", u"" ),
  46. settings[ u"global" ].get( u"luminotes.support_email", u"" ),
  47. settings[ u"global" ].get( u"luminotes.payment_email", u"" ),
  48. settings[ u"global" ].get( u"luminotes.rate_plans", [] ),
  49. settings[ u"global" ].get( u"luminotes.download_products", [] ),
  50. )
  51. self.__groups = Groups( database, self.__users )
  52. self.__files = Files(
  53. database,
  54. self.__users,
  55. settings[ u"global" ].get( u"luminotes.download_products", [] ),
  56. settings[ u"global" ].get( u"luminotes.web_server", "" ),
  57. )
  58. self.__notebooks = Notebooks( database, self.__users, self.__files, settings[ u"global" ].get( u"luminotes.https_url", u"" ) )
  59. self.__forums = Forums( database, self.__notebooks, self.__users )
  60. self.__blog = Forum( database, self.__notebooks, self.__users, u"blog" )
  61. self.__suppress_exceptions = suppress_exceptions # used for unit tests
  62. @expose( Main_page )
  63. @end_transaction
  64. @grab_user_id
  65. @validate(
  66. note_title = unicode,
  67. invite_id = Valid_id( none_okay = True ),
  68. after_login = Valid_string( min = 0, max = 1000 ),
  69. plan = Valid_int( none_okay = True ),
  70. yearly = Valid_bool( none_okay = True ),
  71. user_id = Valid_id( none_okay = True ),
  72. )
  73. def default( self, note_title, invite_id = None, after_login = None, plan = None, yearly = False, user_id = None ):
  74. """
  75. Convenience method for accessing a note in the main notebook by name rather than by note id.
  76. @type note_title: unicode
  77. @param note_title: title of the note to return
  78. @type invite_id: unicode
  79. @param invite_id: id of the invite used to get to this note (optional)
  80. @type after_login: unicode
  81. @param after_login: URL to redirect to after login (optional, must start with "/")
  82. @type plan: int
  83. @param plan: rate plan index (optional, defaults to None)
  84. @type yearly: bool
  85. @param yearly: True for yearly plan, False for monthly (optional, defaults to False)
  86. @rtype: unicode
  87. @return: rendered HTML page
  88. """
  89. # if the user is logged in and not using https, and they request the sign up or login note, then
  90. # redirect to the https version of the page (if available)
  91. https_url = self.__settings[ u"global" ].get( u"luminotes.https_url" )
  92. https_proxy_ip = self.__settings[ u"global" ].get( u"luminotes.https_proxy_ip" )
  93. if note_title in ( u"sign_up", u"login" ) and https_url and cherrypy.request.remote_addr != https_proxy_ip:
  94. if invite_id:
  95. return dict( redirect = u"%s/%s?invite_id=%s" % ( https_url, note_title, invite_id ) )
  96. if after_login:
  97. return dict( redirect = u"%s/%s?after_login=%s" % ( https_url, note_title, after_login ) )
  98. if plan:
  99. return dict( redirect = u"%s/%s?plan=%s&yearly=%s" % ( https_url, note_title, plan, yearly ) )
  100. else:
  101. return dict( redirect = u"%s/%s" % ( https_url, note_title ) )
  102. anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ) )
  103. if anonymous:
  104. main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )
  105. result = self.__users.current( user_id = user_id )
  106. note_title = note_title.replace( u"_", " " )
  107. note = self.__database.select_one( Note, main_notebook.sql_load_note_by_title( note_title ) )
  108. if not note:
  109. raise cherrypy.NotFound
  110. result.update( self.__notebooks.contents( main_notebook.object_id, user_id = user_id, note_id = note.object_id ) )
  111. if invite_id:
  112. result[ "invite_id" ] = invite_id
  113. if after_login and after_login.startswith( u"/" ):
  114. result[ "after_login" ] = after_login
  115. if plan:
  116. result[ "signup_plan" ] = plan
  117. result[ "signup_yearly" ] = yearly
  118. return result
  119. @expose()
  120. def r( self, password_reset_id ):
  121. """
  122. Redirect to the password reset URL, based on the given password_reset id. The sole purpose of
  123. this method is to shorten password reset URLs sent by email so email clients don't wrap them.
  124. """
  125. # if the value looks like an id, it's a password reset id, so redirect
  126. try:
  127. validator = Valid_id()
  128. password_reset_id = validator( password_reset_id )
  129. except ValueError:
  130. raise cherrypy.NotFound
  131. return dict(
  132. redirect = u"/users/redeem_reset/%s" % password_reset_id,
  133. )
  134. @expose()
  135. def i( self, invite_id ):
  136. """
  137. Redirect to the invite redemption URL, based on the given invite id. The sole purpose of this
  138. method is to shorten invite redemption URLs sent by email so email clients don't wrap them.
  139. """
  140. # if the value looks like an id, it's an invite id, so redirect
  141. try:
  142. validator = Valid_id()
  143. invite_id = validator( invite_id )
  144. except ValueError:
  145. raise cherrypy.NotFound
  146. return dict(
  147. redirect = u"/users/redeem_invite/%s" % invite_id,
  148. )
  149. @expose()
  150. def d( self, download_access_id ):
  151. """
  152. Redirect to the product download thanks URL, based on the given download access id. The sole
  153. purpose of this method is to shorten product download URLs sent by email so email clients don't
  154. wrap them.
  155. """
  156. # if the value looks like an id, it's a download access id, so redirect
  157. try:
  158. validator = Valid_id()
  159. download_access_id = validator( download_access_id )
  160. except ValueError:
  161. raise cherrypy.NotFound
  162. return dict(
  163. redirect = u"/users/thanks_download?access_id=%s" % download_access_id,
  164. )
  165. @expose( view = Front_page )
  166. @strongly_expire
  167. @end_transaction
  168. @grab_user_id
  169. @update_auth
  170. @validate(
  171. user_id = Valid_id( none_okay = True ),
  172. )
  173. def index( self, user_id ):
  174. """
  175. Provide the information necessary to display the web site's front page, potentially performing
  176. a redirect to the https version of the page or the user's first notebook.
  177. """
  178. https_url = self.__settings[ u"global" ].get( u"luminotes.https_url" )
  179. https_proxy_ip = self.__settings[ u"global" ].get( u"luminotes.https_proxy_ip" )
  180. # if the server is configured to auto-login a particular user, log that user in and redirect to
  181. # their first notebook
  182. auto_login_username = self.__settings[ u"global" ].get( u"luminotes.auto_login_username" )
  183. if auto_login_username:
  184. user = self.__database.select_one( User, User.sql_load_by_username( auto_login_username ), use_cache = True )
  185. if user and user.username:
  186. first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) )
  187. if first_notebook:
  188. return dict(
  189. redirect = u"/notebooks/%s" % first_notebook.object_id,
  190. authenticated = user,
  191. )
  192. # if the user is logged in and the HTTP request has no referrer, then redirect to the user's
  193. # first notebook
  194. if user_id:
  195. referer = cherrypy.request.headerMap.get( u"Referer" )
  196. if not referer:
  197. user = self.__database.load( User, user_id )
  198. if user and user.username:
  199. first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) )
  200. if first_notebook:
  201. return dict( redirect = u"%s/notebooks/%s" % ( https_url, first_notebook.object_id ) )
  202. # if the user is logged in and not using https, then redirect to the https version of the page (if available)
  203. if https_url and cherrypy.request.remote_addr != https_proxy_ip:
  204. return dict( redirect = u"%s/" % https_url )
  205. result = self.__users.current( user_id )
  206. parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
  207. if len( parents ) > 0:
  208. result[ "first_notebook" ] = parents[ 0 ]
  209. else:
  210. result[ "first_notebook" ] = None
  211. return result
  212. @expose( view = Tour_page )
  213. @end_transaction
  214. @grab_user_id
  215. @validate(
  216. user_id = Valid_id( none_okay = True ),
  217. )
  218. def tour( self, user_id ):
  219. result = self.__users.current( user_id )
  220. parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
  221. if len( parents ) > 0:
  222. result[ "first_notebook" ] = parents[ 0 ]
  223. else:
  224. result[ "first_notebook" ] = None
  225. return result
  226. @expose()
  227. def take_a_tour( self ):
  228. return dict( redirect = u"/tour" )
  229. @expose( view = Main_page )
  230. @end_transaction
  231. @grab_user_id
  232. @validate(
  233. note_id = Valid_id( none_okay = True ),
  234. user_id = Valid_id( none_okay = True ),
  235. )
  236. def guide( self, note_id = None, user_id = None ):
  237. """
  238. Provide the information necessary to display the Luminotes user guide.
  239. @type note_id: unicode or NoneType
  240. @param note_id: id of single note to load (optional)
  241. @rtype: unicode
  242. @return: rendered HTML page
  243. @raise Validation_error: one of the arguments is invalid
  244. """
  245. result = self.__users.current( user_id )
  246. anon_result = self.__users.current( None )
  247. guide_notebooks = [ nb for nb in anon_result[ "notebooks" ] if nb.name == u"Luminotes user guide" ]
  248. result.update( self.__notebooks.contents( guide_notebooks[ 0 ].object_id, user_id = user_id ) )
  249. # if a single note was requested, just return that one note
  250. if note_id:
  251. result[ "startup_notes" ] = [ note for note in result[ "startup_notes" ] if note.object_id == note_id ]
  252. return result
  253. @expose( view = Main_page )
  254. @end_transaction
  255. @grab_user_id
  256. @validate(
  257. user_id = Valid_id( none_okay = True ),
  258. )
  259. def privacy( self, user_id = None ):
  260. """
  261. Provide the information necessary to display the Luminotes privacy policy.
  262. @rtype: unicode
  263. @return: rendered HTML page
  264. @raise Validation_error: one of the arguments is invalid
  265. """
  266. result = self.__users.current( user_id )
  267. anon_result = self.__users.current( None )
  268. privacy_notebooks = [ nb for nb in anon_result[ "notebooks" ] if nb.name == u"Luminotes privacy policy" ]
  269. result.update( self.__notebooks.contents( privacy_notebooks[ 0 ].object_id, user_id = user_id ) )
  270. return result
  271. @expose( view = Upgrade_page )
  272. @strongly_expire
  273. @end_transaction
  274. @grab_user_id
  275. @validate(
  276. user_id = Valid_id( none_okay = True ),
  277. )
  278. def pricing( self, user_id = None ):
  279. """
  280. Provide the information necessary to display the Luminotes pricing page.
  281. """
  282. result = self.__users.current( user_id )
  283. parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
  284. if len( parents ) > 0:
  285. result[ "first_notebook" ] = parents[ 0 ]
  286. else:
  287. result[ "first_notebook" ] = None
  288. result[ "rate_plans" ] = self.__settings[ u"global" ].get( u"luminotes.rate_plans", [] )
  289. result[ "unsubscribe_button" ] = self.__settings[ u"global" ].get( u"luminotes.unsubscribe_button" )
  290. return result
  291. @expose()
  292. def upgrade( self ):
  293. return dict(
  294. redirect = u"/pricing",
  295. )
  296. @expose()
  297. def support( self ):
  298. return dict(
  299. redirect = u"/community",
  300. )
  301. @expose( view = Download_page )
  302. @strongly_expire
  303. @end_transaction
  304. @grab_user_id
  305. @validate(
  306. upgrade = Valid_bool( none_okay = True ),
  307. user_id = Valid_id( none_okay = True ),
  308. )
  309. def download( self, upgrade = False, user_id = None ):
  310. """
  311. Provide the information necessary to display the Luminotes download page.
  312. """
  313. result = self.__users.current( user_id )
  314. parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
  315. if len( parents ) > 0:
  316. result[ "first_notebook" ] = parents[ 0 ]
  317. else:
  318. result[ "first_notebook" ] = None
  319. result[ "download_products" ] = self.__settings[ u"global" ].get( u"luminotes.download_products" )
  320. referer = cherrypy.request.headerMap.get( u"Referer" )
  321. result[ "upgrade" ] = upgrade or ( referer and u"localhost:" in referer )
  322. return result
  323. # TODO: move this method to controller.Notebooks, and maybe give it a more sensible name
  324. @expose( view = Json )
  325. @end_transaction
  326. def next_id( self ):
  327. """
  328. Return the next available database object id for a new note. This id is guaranteed to be unique
  329. among all existing notes.
  330. @rtype: json dict
  331. @return: { 'next_id': nextid }
  332. """
  333. next_id = self.__database.next_id( Note )
  334. return dict(
  335. next_id = next_id,
  336. )
  337. @expose( view = Json )
  338. def ping( self ):
  339. return dict(
  340. response = u"pong",
  341. )
  342. @expose( view = Json )
  343. def shutdown( self ):
  344. # this is typically only allowed in the desktop configuration
  345. if self.__settings[ u"global" ].get( u"luminotes.allow_shutdown_command" ) is not True:
  346. return dict()
  347. cherrypy.server.stop()
  348. return dict()
  349. @expose( view = Close_page )
  350. def close( self ):
  351. # this is typically only allowed in the desktop configuration
  352. if self.__settings[ u"global" ].get( u"luminotes.allow_shutdown_command" ) is not True:
  353. return dict()
  354. cherrypy.server.stop()
  355. return dict()
  356. def _cp_on_http_error( self, status, message ):
  357. """
  358. CherryPy HTTP error handler, used to display page not found and generic error pages.
  359. """
  360. support_email = self.__settings[ u"global" ].get( u"luminotes.support_email" )
  361. if status == 404:
  362. cherrypy.response.headerMap[ u"Status" ] = u"404 Not Found"
  363. cherrypy.response.status = status
  364. cherrypy.response.body = [ unicode( Not_found_page( support_email ) ) ]
  365. return
  366. import traceback
  367. if not self.__suppress_exceptions:
  368. cherrypy.log( traceback.format_exc() )
  369. self.report_traceback()
  370. import sys
  371. error = sys.exc_info()[ 1 ]
  372. if hasattr( error, "to_dict" ):
  373. error_message = error.to_dict().get( u"error" )
  374. else:
  375. error_message = None
  376. cherrypy.response.body = [ unicode( Error_page( support_email, message = error_message ) ) ]
  377. def report_traceback( self ):
  378. """
  379. If a support email address is configured, send it an email with the current traceback.
  380. """
  381. support_email = self.__settings[ u"global" ].get( u"luminotes.support_email" )
  382. if not support_email: return False
  383. import smtplib
  384. import traceback
  385. from email import Message
  386. message = Message.Message()
  387. message[ u"From" ] = support_email
  388. message[ u"To" ] = support_email
  389. message[ u"Subject" ] = u"Luminotes traceback"
  390. message.set_payload(
  391. u"requested URL: %s\n" % cherrypy.request.browser_url +
  392. u"user id: %s\n" % cherrypy.session.get( "user_id" ) +
  393. u"username: %s\n\n" % cherrypy.session.get( "username" ) +
  394. traceback.format_exc()
  395. )
  396. # send the message out through localhost's smtp server
  397. server = smtplib.SMTP()
  398. server.connect()
  399. server.sendmail( message[ u"From" ], [ support_email ], message.as_string() )
  400. server.quit()
  401. return True
  402. database = property( lambda self: self.__database )
  403. notebooks = property( lambda self: self.__notebooks )
  404. users = property( lambda self: self.__users )
  405. groups = property( lambda self: self.__groups )
  406. files = property( lambda self: self.__files )
  407. forums = property( lambda self: self.__forums )
  408. blog = property( lambda self: self.__blog )