Beispiel #1
0
    def default(self,
                thread_id,
                start=0,
                count=10,
                note_id=None,
                posts=None,
                user_id=None):
        """
    Provide the information necessary to display a forum thread.

    @type thread_id: unicode
    @param thread_id: id or "friendly id" of thread notebook to display
    @type start: unicode or NoneType
    @param start: index of recent note to start with (defaults to 0, the most recent note)
    @type count: int or NoneType
    @param count: number of recent notes to display (defaults to 10 notes)
    @type note_id: unicode or NoneType
    @param note_id: id of single note to load (optional)
    @type posts: integer or NoneType
    @param posts: ignored. used for link-visitedness purposes on the client side
    @type user_id: unicode or NoneType
    @param user_id: id of the current user
    @rtype: unicode
    @return: rendered HTML page
    @raise Validation_error: one of the arguments is invalid
    """
        # first try loading the thread by id, and then if not found, try loading by "friendly id"
        try:
            Valid_id()(thread_id)
            if not self.__database.load(Notebook, thread_id):
                raise ValueError()
        except ValueError:
            try:
                Valid_friendly_id()(thread_id)
            except ValueError:
                raise cherrypy.NotFound

            try:
                thread = self.__database.select_one(
                    Notebook, Notebook.sql_load_by_friendly_id(thread_id))
            except:
                raise cherrypy.NotFound
            if not thread:
                raise cherrypy.NotFound

            thread_id = thread.object_id

        result = self.__users.current(user_id)
        result.update(
            self.__notebooks.old_notes(thread_id, start, count, user_id))

        # if a single note was requested, just return that one note
        if note_id:
            note = self.__database.load(Note, note_id)
            if note:
                result["notes"] = [note]
            else:
                result["notes"] = []

        return result
Beispiel #2
0
class Forums(object):
    """
  Controller for dealing with discussion forums, corresponding to the "/forums" URL.
  """
    def __init__(self, database, notebooks, users):
        """
    Create a new Forums object, representing a collection of forums.

    @type database: controller.Database
    @param database: database that forums are stored in
    @type notebooks: controller.Users
    @param notebooks: controller for all notebooks
    @type users: controller.Users
    @param users: controller for all users
    @rtype: Forums
    @return: newly constructed Forums
    """
        self.__database = database
        self.__notebooks = notebooks
        self.__users = users

        self.__general = Forum(database, notebooks, users, u"general")
        self.__support = Forum(database, notebooks, users, u"support")

    @expose(view=Forums_page)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        user_id=Valid_id(none_okay=True), )
    def index(self, user_id):
        """
    Provide the information necessary to display the listing of available forums (currently hard-coded).

    @type user_id: unicode or NoneType
    @param user_id: id of the current user
    """
        result = self.__users.current(user_id)
        parents = [
            notebook for notebook in result[u"notebooks"]
            if notebook.trash_id and not notebook.deleted
        ]
        if len(parents) > 0:
            result["first_notebook"] = parents[0]
        else:
            result["first_notebook"] = None

        return result

    general = property(lambda self: self.__general)
    support = property(lambda self: self.__support)
Beispiel #3
0
  def i( self, invite_id ):
    """
    Redirect to the invite redemption URL, based on the given invite id. The sole purpose of this
    method is to shorten invite redemption URLs sent by email so email clients don't wrap them.
    """
    # if the value looks like an id, it's an invite id, so redirect
    try:
      validator = Valid_id()
      invite_id = validator( invite_id )
    except ValueError:
      raise cherrypy.NotFound

    return dict(
      redirect = u"/users/redeem_invite/%s" % invite_id,
    )
Beispiel #4
0
  def r( self, password_reset_id ):
    """
    Redirect to the password reset URL, based on the given password_reset id. The sole purpose of
    this method is to shorten password reset URLs sent by email so email clients don't wrap them.
    """
    # if the value looks like an id, it's a password reset id, so redirect
    try:
      validator = Valid_id()
      password_reset_id = validator( password_reset_id )
    except ValueError:
      raise cherrypy.NotFound

    return dict(
      redirect = u"/users/redeem_reset/%s" % password_reset_id,
    )
Beispiel #5
0
  def d( self, download_access_id ):
    """
    Redirect to the product download thanks URL, based on the given download access id. The sole
    purpose of this method is to shorten product download URLs sent by email so email clients don't
    wrap them.
    """
    # if the value looks like an id, it's a download access id, so redirect
    try:
      validator = Valid_id()
      download_access_id = validator( download_access_id )
    except ValueError:
      raise cherrypy.NotFound

    return dict(
      redirect = u"/users/thanks_download?access_id=%s" % download_access_id,
    )
Beispiel #6
0
    def make_file(self, binary=None):
        global current_uploads, current_uploads_lock

        cherrypy.response.timeout = 3600 * 2  # increase upload timeout to 2 hours (default is 5 min)
        cherrypy.server.socket_timeout = 60  # increase socket timeout to one minute (default is 10 sec)
        DASHES_AND_NEWLINES = 6  # four dashes and two newlines

        # pluck the file id out of the query string. it would be preferable to grab it out of parsed
        # form variables instead, but at this point in the processing, all the form variables might not
        # be parsed
        file_id = cgi.parse_qs(cherrypy.request.query_string).get(
            u"X-Progress-ID", [None])[0]
        try:
            file_id = Valid_id()(file_id)
        except ValueError:
            raise Upload_error("The file_id is invalid.")

        self.filename = unicode(
            self.filename.split("/")[-1].split("\\")[-1].strip(), "utf8")

        if not self.filename:
            raise Upload_error("Please provide a filename.")

        content_length = cherrypy.request.headers.get("content-length", 0)
        try:
            content_length = Valid_int(min=0)(content_length) - len(
                self.outerboundary) - DASHES_AND_NEWLINES
        except ValueError:
            raise Upload_error("The Content-Length header value is invalid.")

        # file size is the entire content length of the POST, minus the size of the other form
        # parameters and boundaries. note: this assumes that the uploaded file is sent as the last
        # form parameter in the POST
        existing_file = current_uploads.get(file_id)
        if existing_file:
            existing_file.close()

        upload_file = Upload_file(file_id, self.filename, content_length)

        current_uploads_lock.acquire()
        try:
            current_uploads[file_id] = upload_file
        finally:
            current_uploads_lock.release()

        return upload_file
Beispiel #7
0
class Forum(object):
    DEFAULT_THREAD_NAME = u"new discussion"

    def __init__(self, database, notebooks, users, name):
        """
    Create a new Forum object, representing a single forum.

    @type database: controller.Database
    @param database: database that forums are stored in
    @type notebooks: controller.Users
    @param notebooks: controller for all notebooks
    @type users: controller.Users
    @param users: controller for all users
    @type name: unicode
    @param name: one-word name of this forum
    @rtype: Forums
    @return: newly constructed Forums
    """
        self.__database = database
        self.__notebooks = notebooks
        self.__users = users
        self.__name = name

    @expose(view=Forum_page, rss=Forum_rss)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        start=Valid_int(min=0),
        count=Valid_int(min=1, max=50),
        user_id=Valid_id(none_okay=True),
        note_id=Valid_id(none_okay=True),
    )
    def index(self, start=0, count=50, note_id=None, user_id=None):
        """
    Provide the information necessary to display the current threads within a forum.

    @type start: integer or NoneType
    @param start: index of first forum thread to display (optional, defaults to 0)
    @type count: integer or NoneType
    @param count: how many forum threads to display (optional, defaults to quite a few)
    @type note_id: unicode or NoneType
    @param note_id: id of thread to redirect to (optional, legacy support for old URLs)
    @type user_id: unicode or NoneType
    @param user_id: id of the current user
    @rtype: unicode
    @return: rendered HTML page
    """
        if note_id:
            return dict(redirect=os.path.join(cherrypy.request.path, note_id))

        result = self.__users.current(user_id)
        parents = [
            notebook for notebook in result[u"notebooks"]
            if notebook.trash_id and not notebook.deleted
        ]
        if len(parents) > 0:
            result["first_notebook"] = parents[0]
        else:
            result["first_notebook"] = None

        anonymous = self.__database.select_one(
            User, User.sql_load_by_username(u"anonymous"), use_cache=True)
        if anonymous is None:
            raise Access_error()

        # load a slice of the list of the threads in this forum, excluding those with a default name
        threads = self.__database.select_many(
            Notebook,
            anonymous.sql_load_notebooks(
                parents_only=False,
                undeleted_only=True,
                tag_name=u"forum",
                tag_value=self.__name,
                exclude_notebook_name=self.DEFAULT_THREAD_NAME,
                reverse=True,
                start=start,
                count=count,
            ))

        # if there are no matching threads, then this forum doesn't exist
        if len(threads) == 0:
            raise cherrypy.NotFound

        # count the total number of threads in this forum, excluding those with a default name
        total_thread_count = self.__database.select_one(
            int,
            anonymous.sql_count_notebooks(
                parents_only=False,
                undeleted_only=True,
                tag_name=u"forum",
                tag_value=self.__name,
                exclude_notebook_name=self.DEFAULT_THREAD_NAME,
            ))

        result["forum_name"] = self.__name
        result["threads"] = threads
        result["start"] = start
        result["count"] = count
        result["total_thread_count"] = total_thread_count
        return result

    @expose(view=Main_page)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        thread_id=unicode,
        start=Valid_int(min=0),
        count=Valid_int(min=1, max=50),
        note_id=Valid_id(none_okay=True),
        posts=Valid_int(),
        user_id=Valid_id(none_okay=True),
    )
    def default(self,
                thread_id,
                start=0,
                count=10,
                note_id=None,
                posts=None,
                user_id=None):
        """
    Provide the information necessary to display a forum thread.

    @type thread_id: unicode
    @param thread_id: id or "friendly id" of thread notebook to display
    @type start: unicode or NoneType
    @param start: index of recent note to start with (defaults to 0, the most recent note)
    @type count: int or NoneType
    @param count: number of recent notes to display (defaults to 10 notes)
    @type note_id: unicode or NoneType
    @param note_id: id of single note to load (optional)
    @type posts: integer or NoneType
    @param posts: ignored. used for link-visitedness purposes on the client side
    @type user_id: unicode or NoneType
    @param user_id: id of the current user
    @rtype: unicode
    @return: rendered HTML page
    @raise Validation_error: one of the arguments is invalid
    """
        # first try loading the thread by id, and then if not found, try loading by "friendly id"
        try:
            Valid_id()(thread_id)
            if not self.__database.load(Notebook, thread_id):
                raise ValueError()
        except ValueError:
            try:
                Valid_friendly_id()(thread_id)
            except ValueError:
                raise cherrypy.NotFound

            try:
                thread = self.__database.select_one(
                    Notebook, Notebook.sql_load_by_friendly_id(thread_id))
            except:
                raise cherrypy.NotFound
            if not thread:
                raise cherrypy.NotFound

            thread_id = thread.object_id

        result = self.__users.current(user_id)
        result.update(
            self.__notebooks.old_notes(thread_id, start, count, user_id))

        # if a single note was requested, just return that one note
        if note_id:
            note = self.__database.load(Note, note_id)
            if note:
                result["notes"] = [note]
            else:
                result["notes"] = []

        return result

    @expose()
    @end_transaction
    @grab_user_id
    @validate(
        user_id=Valid_id(none_okay=True), )
    def create_thread(self, user_id):
        """
    Create a new forum thread with a blank post, and give the thread a default name. Then redirect
    to that new thread.

    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype dict
    @return { 'redirect': new_notebook_url }
    @raise Access_error: the current user doesn't have access to create a post
    @raise Validation_error: one of the arguments is invalid
    """
        if user_id is None:
            raise Access_error()

        user = self.__database.load(User, user_id)
        if user is None or not user.username or user.username == "anonymous":
            raise Access_error()

        anonymous = self.__database.select_one(
            User, User.sql_load_by_username(u"anonymous"), use_cache=True)
        if anonymous is None:
            raise Access_error()

        # for now, crappy hard-coding to prevent just anyone from creating a blog thread
        if self.__name == u"blog" and user.username != u"witten":
            raise Access_error()

        # create the new notebook thread
        thread_id = self.__database.next_id(Notebook, commit=False)
        thread = Notebook.create(thread_id,
                                 self.DEFAULT_THREAD_NAME,
                                 user_id=user.object_id)
        self.__database.save(thread, commit=False)

        # associate the forum tag with the new notebook thread
        tag = self.__database.select_one(
            Tag, Tag.sql_load_by_name(u"forum", user_id=anonymous.object_id))
        self.__database.execute(
            anonymous.sql_save_notebook_tag(thread_id,
                                            tag.object_id,
                                            value=self.__name),
            commit=False,
        )

        # give the anonymous user access to the new notebook thread
        self.__database.execute(
            anonymous.sql_save_notebook(thread_id,
                                        read_write=True,
                                        owner=False,
                                        own_notes_only=True),
            commit=False,
        )

        # create a blank post in which the user can  start off the thread
        note_id = self.__database.next_id(Notebook, commit=False)
        note = Note.create(note_id,
                           u"<h3>",
                           notebook_id=thread_id,
                           startup=True,
                           rank=0,
                           user_id=user_id)
        self.__database.save(note, commit=False)

        self.__database.commit()

        if self.__name == "blog":
            return dict(redirect=u"/blog/%s" % thread_id, )

        return dict(redirect=u"/forums/%s/%s" % (self.__name, thread_id), )
Beispiel #8
0
class Root( object ):
  """
  The root of the controller hierarchy, corresponding to the "/" URL.
  """
  def __init__( self, database, settings, suppress_exceptions = False ):
    """
    Create a new Root object with the given settings.

    @type database: controller.Database
    @param database: database to use for all controllers
    @type settings: dict
    @param settings: CherryPy-style settings 
    @rtype: Root
    @return: newly constructed Root
    """
    self.__database = database
    self.__settings = settings
    self.__users = Users(
      database,
      settings[u"luminotes.http_url"],
      settings[u"luminotes.https_url"],
      settings[u"luminotes.support_email"],
      settings[u"luminotes.payment_email"],
      settings[u"luminotes.rate_plans"],
      settings[u"luminotes.download_products"],
    )
    self.__groups = Groups( database, self.__users )
    self.__files = Files(
      database,
      self.__users,
      settings[u"luminotes.download_products"],
      settings[u"luminotes.web_server"],
    )
    self.__notebooks = Notebooks( database, self.__users, self.__files, settings[ u"luminotes.https_url"] )
    self.__forums = Forums( database, self.__notebooks, self.__users )
    self.__blog = Forum( database, self.__notebooks, self.__users, u"blog" )
    self.__suppress_exceptions = suppress_exceptions # used for unit tests

  @expose( Main_page )
  @end_transaction
  @grab_user_id
  @validate(
    note_title = unicode,
    invite_id = Valid_id( none_okay = True ),
    after_login = Valid_string( min = 0, max = 1000 ),
    plan = Valid_int( none_okay = True ),
    yearly = Valid_bool( none_okay = True ),
    user_id = Valid_id( none_okay = True ),
  )
  def default( self, note_title, invite_id = None, after_login = None, plan = None, yearly = False, user_id = None ):
    """
    Convenience method for accessing a note in the main notebook by name rather than by note id.

    @type note_title: unicode
    @param note_title: title of the note to return
    @type invite_id: unicode
    @param invite_id: id of the invite used to get to this note (optional)
    @type after_login: unicode
    @param after_login: URL to redirect to after login (optional, must start with "/")
    @type plan: int
    @param plan: rate plan index (optional, defaults to None)
    @type yearly: bool
    @param yearly: True for yearly plan, False for monthly (optional, defaults to False)
    @rtype: unicode
    @return: rendered HTML page
    """
    # if the user is logged in and not using https, and they request the sign up or login note, then
    # redirect to the https version of the page (if available)
    https_url = self.__settings[u"luminotes.https_url"]
    https_proxy_ip = self.__settings[u"luminotes.https_proxy_ip"]
    
    if note_title in ( u"sign_up", u"login" ) and https_url and cherrypy.request.remote_addr != https_proxy_ip:
      if invite_id:
        return dict( redirect = u"%s/%s?invite_id=%s" % ( https_url, note_title, invite_id ) )
      if after_login:
        return dict( redirect = u"%s/%s?after_login=%s" % ( https_url, note_title, after_login ) )
      if plan:
        return dict( redirect = u"%s/%s?plan=%s&yearly=%s" % ( https_url, note_title, plan, yearly ) )
      else:
        return dict( redirect = u"%s/%s" % ( https_url, note_title ) )

    anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ) )
    if anonymous:
      main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) )

    result = self.__users.current( user_id = user_id )

    note_title = note_title.replace( u"_", " " )
    note = self.__database.select_one( Note, main_notebook.sql_load_note_by_title( note_title ) )
    if not note:
      raise cherrypy.NotFound

    result.update( self.__notebooks.contents( main_notebook.object_id, user_id = user_id, note_id = note.object_id ) )
    if invite_id:
      result[ "invite_id" ] = invite_id
    if after_login and after_login.startswith( u"/" ):
      result[ "after_login" ] = after_login
    if plan:
      result[ "signup_plan" ] = plan
      result[ "signup_yearly" ] = yearly

    return result

  @expose()
  def r( self, password_reset_id ):
    """
    Redirect to the password reset URL, based on the given password_reset id. The sole purpose of
    this method is to shorten password reset URLs sent by email so email clients don't wrap them.
    """
    # if the value looks like an id, it's a password reset id, so redirect
    try:
      validator = Valid_id()
      password_reset_id = validator( password_reset_id )
    except ValueError:
      raise cherrypy.NotFound

    return dict(
      redirect = u"/users/redeem_reset/%s" % password_reset_id,
    )

  @expose()
  def i( self, invite_id ):
    """
    Redirect to the invite redemption URL, based on the given invite id. The sole purpose of this
    method is to shorten invite redemption URLs sent by email so email clients don't wrap them.
    """
    # if the value looks like an id, it's an invite id, so redirect
    try:
      validator = Valid_id()
      invite_id = validator( invite_id )
    except ValueError:
      raise cherrypy.NotFound

    return dict(
      redirect = u"/users/redeem_invite/%s" % invite_id,
    )

  @expose()
  def d( self, download_access_id ):
    """
    Redirect to the product download thanks URL, based on the given download access id. The sole
    purpose of this method is to shorten product download URLs sent by email so email clients don't
    wrap them.
    """
    # if the value looks like an id, it's a download access id, so redirect
    try:
      validator = Valid_id()
      download_access_id = validator( download_access_id )
    except ValueError:
      raise cherrypy.NotFound

    return dict(
      redirect = u"/users/thanks_download?access_id=%s" % download_access_id,
    )

  @expose( view = Front_page )
  @strongly_expire
  @end_transaction
  @grab_user_id
  @update_auth
  @validate(
    user_id = Valid_id( none_okay = True ),
  )
  def index( self, user_id ):
    """
    Provide the information necessary to display the web site's front page, potentially performing
    a redirect to the https version of the page or the user's first notebook.
    """
    https_url = self.__settings[u"luminotes.https_url"]
    https_proxy_ip = self.__settings[u"luminotes.https_proxy_ip"]

    # if the server is configured to auto-login a particular user, log that user in and redirect to
    # their first notebook
    auto_login_username = self.__settings[u"luminotes.auto_login_username"]
    if auto_login_username:
      user = self.__database.select_one( User, User.sql_load_by_username( auto_login_username ), use_cache = True )

      if user and user.username:
        first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) )
        if first_notebook:
          return dict(
            redirect = u"/notebooks/%s" % first_notebook.object_id,
            authenticated = user,
          )

    # if the user is logged in and the HTTP request has no referrer, then redirect to the user's
    # first notebook
    if user_id:
      referer = cherrypy.request.headerMap.get( u"Referer" )
      if not referer:
        user = self.__database.load( User, user_id )

        if user and user.username:
          first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) )
          if first_notebook:
            return dict( redirect = u"%s/notebooks/%s" % ( https_url, first_notebook.object_id ) )
      
      # if the user is logged in and not using https, then redirect to the https version of the page (if available)
      if https_url and cherrypy.request.remote_addr != https_proxy_ip:
        return dict( redirect = u"%s/" % https_url )

    result = self.__users.current( user_id )
    parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
    if len( parents ) > 0:
      result[ "first_notebook" ] = parents[ 0 ]
    else:
      result[ "first_notebook" ] = None

    return result

  @expose( view = Tour_page )
  @end_transaction
  @grab_user_id
  @validate(
    user_id = Valid_id( none_okay = True ),
  )
  def tour( self, user_id ):
    result = self.__users.current( user_id )
    parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
    if len( parents ) > 0:
      result[ "first_notebook" ] = parents[ 0 ]
    else:
      result[ "first_notebook" ] = None

    return result

  @expose()
  def take_a_tour( self ):
    return dict( redirect = u"/tour" )

  @expose( view = Main_page )
  @end_transaction
  @grab_user_id
  @validate(
    note_id = Valid_id( none_okay = True ),
    user_id = Valid_id( none_okay = True ),
  )
  def guide( self, note_id = None, user_id = None ):
    """
    Provide the information necessary to display the Luminotes user guide.

    @type note_id: unicode or NoneType
    @param note_id: id of single note to load (optional)
    @rtype: unicode
    @return: rendered HTML page
    @raise Validation_error: one of the arguments is invalid
    """
    result = self.__users.current( user_id )
    anon_result = self.__users.current( None )
    guide_notebooks = [ nb for nb in anon_result[ "notebooks" ] if nb.name == u"Luminotes user guide" ]

    result.update( self.__notebooks.contents( guide_notebooks[ 0 ].object_id, user_id = user_id ) )

    # if a single note was requested, just return that one note
    if note_id:
      result[ "startup_notes" ] = [ note for note in result[ "startup_notes" ] if note.object_id == note_id ]

    return result

  @expose( view = Main_page )
  @end_transaction
  @grab_user_id
  @validate(
    user_id = Valid_id( none_okay = True ),
  )
  def privacy( self, user_id = None ):
    """
    Provide the information necessary to display the Luminotes privacy policy.

    @rtype: unicode
    @return: rendered HTML page
    @raise Validation_error: one of the arguments is invalid
    """
    result = self.__users.current( user_id )
    anon_result = self.__users.current( None )
    privacy_notebooks = [ nb for nb in anon_result[ "notebooks" ] if nb.name == u"Luminotes privacy policy" ]

    result.update( self.__notebooks.contents( privacy_notebooks[ 0 ].object_id, user_id = user_id ) )

    return result

  @expose( view = Upgrade_page )
  @strongly_expire
  @end_transaction
  @grab_user_id
  @validate(
    user_id = Valid_id( none_okay = True ),
  )
  def pricing( self, user_id = None ):
    """
    Provide the information necessary to display the Luminotes pricing page.
    """
    result = self.__users.current( user_id )
    parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
    if len( parents ) > 0:
      result[ "first_notebook" ] = parents[ 0 ]
    else:
      result[ "first_notebook" ] = None

    result[ "rate_plans" ] = self.__settings[u"luminotes.rate_plans"]
    result[ "unsubscribe_button" ] = self.__settings[u"luminotes.unsubscribe_button"]

    return result

  @expose()
  def upgrade( self ):
    return dict(
      redirect = u"/pricing",
    )

  @expose()
  def support( self ):
    return dict(
      redirect = u"/community",
    )

  @expose( view = Download_page )
  @strongly_expire
  @end_transaction
  @grab_user_id
  @validate(
    upgrade = Valid_bool( none_okay = True ),
    user_id = Valid_id( none_okay = True ),
  )
  def download( self, upgrade = False, user_id = None ):
    """
    Provide the information necessary to display the Luminotes download page.
    """
    result = self.__users.current( user_id )
    parents = [ notebook for notebook in result[ u"notebooks" ] if notebook.trash_id and not notebook.deleted ]
    if len( parents ) > 0:
      result[ "first_notebook" ] = parents[ 0 ]
    else:
      result[ "first_notebook" ] = None

    result[ "download_products" ] = self.__settings[u"luminotes.download_products" ]

    referer = cherrypy.request.headerMap.get( u"Referer" )
    result[ "upgrade" ] = upgrade or ( referer and u"localhost:" in referer )

    return result

  # TODO: move this method to controller.Notebooks, and maybe give it a more sensible name
  @expose( view = Json )
  @end_transaction
  def next_id( self ):
    """
    Return the next available database object id for a new note. This id is guaranteed to be unique
    among all existing notes.

    @rtype: json dict
    @return: { 'next_id': nextid }
    """
    next_id = self.__database.next_id( Note )

    return dict(
      next_id = next_id,
    )

  @expose( view = Json )
  def ping( self ):
    return dict(
      response = u"pong",
    )

  @expose( view = Json )
  def shutdown( self ):
    # this is typically only allowed in the desktop configuration
    if self.__settings[u"luminotes.allow_shutdown_command" ] is not True:
      return dict()

    cherrypy.server.stop()

    return dict()

  @expose( view = Close_page )
  def close( self ):
    # this is typically only allowed in the desktop configuration
    if self.__settings[u"luminotes.allow_shutdown_command"] is not True:
      return dict()

    cherrypy.server.stop()

    return dict()

  def _cp_on_http_error( self, status, message ):
    """
    CherryPy HTTP error handler, used to display page not found and generic error pages.
    """
    support_email = self.__settings[u"luminotes.support_email"]

    if status == 404:
      cherrypy.response.headerMap[ u"Status" ] = u"404 Not Found"
      cherrypy.response.status = status
      cherrypy.response.body = [ unicode( Not_found_page( support_email ) ) ]
      return

    import traceback
    if not self.__suppress_exceptions:
      cherrypy.log( traceback.format_exc() )
    self.report_traceback()

    import sys
    error = sys.exc_info()[ 1 ]
    if hasattr( error, "to_dict" ):
      error_message = error.to_dict().get( u"error" )
    else:
      error_message = None

    cherrypy.response.body = [ unicode( Error_page( support_email, message = error_message ) ) ]

  def report_traceback( self ):
    """
    If a support email address is configured, send it an email with the current traceback.
    """
    support_email = self.__settings[u"luminotes.support_email"]
    if not support_email: return False

    import smtplib
    import traceback
    from email import Message
    
    message = Message.Message()
    message[ u"From" ] = support_email
    message[ u"To" ] = support_email
    message[ u"Subject" ] = u"Luminotes traceback"
    message.set_payload(
      u"requested URL: %s\n" % cherrypy.request.browser_url +
      u"user id: %s\n" % cherrypy.session.get( "user_id" ) +
      u"username: %s\n\n" % cherrypy.session.get( "username" ) +
      traceback.format_exc()
    )

    # send the message out through localhost's smtp server
    server = smtplib.SMTP()
    server.connect()
    server.sendmail( message[ u"From" ], [ support_email ], message.as_string() )
    server.quit()

    return True

  database = property( lambda self: self.__database )
  notebooks = property( lambda self: self.__notebooks )
  users = property( lambda self: self.__users )
  groups = property( lambda self: self.__groups )
  files = property( lambda self: self.__files )
  forums = property( lambda self: self.__forums )
  blog = property( lambda self: self.__blog )
Beispiel #9
0
class Groups(object):
    def __init__(self, database, users):
        self.__database = database
        self.__users = users

    @expose(view=Json)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        group_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def load_users(self, group_id, user_id=None):
        """
    Return the users within the given group. This method is only available to an admin of the
    group.

    @type group_id: unicode
    @param group_id: id of group whose users to return
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: dict
    @return: {
      'group': group_info,
      'admin_users': admin_user_list,
      'other_users': non_admin_user_list,
    }
    @raise Access_error: the current user doesn't have admin membership to the given group
    @raise Validation_error: one of the arguments is invalid
    """
        if not self.__users.check_group(user_id, group_id, admin=True):
            raise Access_error()

        group = self.__database.load(Group, group_id)

        if group is None:
            raise Access_error()

        admin_users = self.__database.select_many(
            User, group.sql_load_users(admin=True))
        other_users = self.__database.select_many(
            User, group.sql_load_users(admin=False))

        return dict(
            group=group,
            admin_users=admin_users,
            other_users=other_users,
        )

    @expose(view=Json)
    @end_transaction
    @grab_user_id
    @validate(
        group_id=Valid_id(),
        group_name=Valid_string(min=0, max=100),
        group_settings_button=unicode,
        user_id=Valid_id(none_okay=True),
    )
    def update_settings(self,
                        group_id,
                        group_name,
                        group_settings_button,
                        user_id=None):
        """
    Update the settings for the given group.

    @type group_id: unicode
    @param group_id: id of group whose users to return
    @type group_name: unicode
    @param group_name: new name of the group
    @type group_settings_button: unicode
    @param group_settings_button: ignored
    @rtype: dict
    @return: { 'message': message }
    @raise Access_error: the current user doesn't have admin membership to the given group
    @raise Validation_error: one of the arguments is invalid
    """
        if not self.__users.check_group(user_id, group_id, admin=True):
            raise Access_error()

        group = self.__database.load(Group, group_id)

        if group is None:
            raise Access_error()

        group.name = group_name
        self.__database.save(group)

        return dict(message=u"The group settings have been saved.", )
Beispiel #10
0
class Files(object):
    FILE_LINK_PATTERN = re.compile(
        u'<a\s+href="[^"]*/files/download\?file_id=([^"&]+)(&[^"]*)?"[^>]*>(<img )?[^<]+</a>',
        re.IGNORECASE)
    """
  Controller for dealing with uploaded files, corresponding to the "/files" URL.
  """
    def __init__(self, database, users, download_products, web_server):
        """
    Create a new Files object.

    @type database: controller.Database
    @param database: database that file metadata is stored in
    @type users: controller.Users
    @param users: controller for all users
    @type download_products: [ { "name": unicode, ... } ]
    @param download_products: list of configured downloadable products
    @type web_server: unicode
    @param web_server: front-end web server (determines specific support for various features)
    @rtype: Files
    @return: newly constructed Files
    """
        self.__database = database
        self.__users = users
        self.__download_products = download_products
        self.__web_server = web_server

    @expose()
    @weakly_expire
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        quote_filename=Valid_bool(none_okay=True),
        preview=Valid_bool(none_okay=True),
        user_id=Valid_id(none_okay=True),
    )
    def download(self,
                 file_id,
                 quote_filename=False,
                 preview=True,
                 user_id=None):
        """
    Return the contents of file that a user has previously uploaded.

    @type file_id: unicode
    @param file_id: id of the file to download
    @type quote_filename: bool
    @param quote_filename: True to URL quote the filename of the downloaded file, False to leave it
                           as UTF-8. IE expects quoting while Firefox doesn't (optional, defaults
                           to False)
    @type preview: bool
    @param preview: True to redirect to a preview page if the file is a valid image, False to
                    unconditionally initiate a download
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: generator
    @return: file data
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)

        if not db_file or not self.__users.load_notebook(
                user_id, db_file.notebook_id):
            raise Access_error()

        # if the file is openable as an image, then allow the user to view it instead of downloading it
        if preview:
            try:
                Upload_file.open_image(file_id)
                return dict(
                    redirect=u"/files/preview?file_id=%s&quote_filename=%s" %
                    (file_id, quote_filename))
            except IOError:
                pass

        cherrypy.response.headerMap[u"Content-Type"] = db_file.content_type

        filename = db_file.filename.replace('"', r"\"").encode("utf8")
        if quote_filename:
            filename = urllib.quote(filename, safe="")

        cherrypy.response.headerMap[
            u"Content-Disposition"] = 'attachment; filename="%s"' % filename
        cherrypy.response.headerMap[u"Content-Length"] = db_file.size_bytes

        if self.__web_server == u"nginx":
            cherrypy.response.headerMap[
                u"X-Accel-Redirect"] = "/download/%s" % file_id
            return ""

        def stream():
            CHUNK_SIZE = 8192
            local_file = Upload_file.open_file(file_id)
            local_file.seek(0)

            while True:
                data = local_file.read(CHUNK_SIZE)
                if len(data) == 0: break
                yield data

        return stream()

    @expose()
    @weakly_expire
    @end_transaction
    @validate(
        access_id=Valid_id(), )
    def download_product(self, access_id):
        """
    Return the contents of downloadable product file.

    @type access_id: unicode
    @param access_id: id of download access object that grants access to the file
    @rtype: generator
    @return: file data
    @raise Access_error: the access_id is unknown or doesn't grant access to the file
    """
        # load the download_access object corresponding to the given id
        download_access = self.__database.load(Download_access, access_id)
        if download_access is None:
            raise Access_error()

        # find the product corresponding to the item_number
        products = [
            product for product in self.__download_products if unicode(
                download_access.item_number) == product.get(u"item_number")
        ]
        if len(products) == 0:
            raise Access_error()

        product = products[0]

        public_filename = product[u"filename"].encode("utf8")
        local_filename = u"products/%s" % product[u"filename"]

        if not os.path.exists(local_filename):
            raise Access_error()

        cherrypy.response.headerMap[
            u"Content-Type"] = u"application/octet-stream"
        cherrypy.response.headerMap[
            u"Content-Disposition"] = 'attachment; filename="%s"' % public_filename
        cherrypy.response.headerMap[u"Content-Length"] = os.path.getsize(
            local_filename)

        if self.__web_server == u"nginx":
            cherrypy.response.headerMap[
                u"X-Accel-Redirect"] = "/download_product/%s" % product[
                    u"filename"]
            return ""

        def stream():
            CHUNK_SIZE = 8192
            local_file = file(local_filename, "rb")
            local_file.seek(0)

            while True:
                data = local_file.read(CHUNK_SIZE)
                if len(data) == 0: break
                yield data

        return stream()

    @expose(view=File_preview_page)
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        quote_filename=Valid_bool(none_okay=True),
        user_id=Valid_id(none_okay=True),
    )
    def preview(self, file_id, quote_filename=False, user_id=None):
        """
    Return a page displaying an uploaded image file along with a link to download it.

    @type file_id: unicode
    @param file_id: id of the file to view
    @type quote_filename: bool
    @param quote_filename: quote_filename value to include in download URL
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: unicode
    @return: file data
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)

        if not db_file or not self.__users.load_notebook(
                user_id, db_file.notebook_id):
            raise Access_error()

        filename = db_file.filename.replace('"', r"\"")

        return dict(
            file_id=file_id,
            filename=filename,
            quote_filename=quote_filename,
        )

    @expose()
    @weakly_expire
    @end_transaction
    @grab_user_id
    @validate(file_id=Valid_id(),
              max_size=Valid_int(min=10, max=1000, none_okay=True),
              user_id=Valid_id(none_okay=True))
    def thumbnail(self, file_id, max_size=None, user_id=None):
        """
    Return a thumbnail for a file that a user has previously uploaded. If a thumbnail cannot be
    generated for the given file, return a default thumbnail image.

    @type file_id: unicode
    @param file_id: id of the file to return a thumbnail for
    @type max_size: int or NoneType
    @param max_size: maximum thumbnail width or height in pixels (optional, defaults to a small size)
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: generator
    @return: thumbnail image data
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)

        if not db_file or not self.__users.load_notebook(
                user_id, db_file.notebook_id):
            raise Access_error()

        cherrypy.response.headerMap[u"Content-Type"] = u"image/png"

        DEFAULT_MAX_THUMBNAIL_SIZE = 125
        if not max_size:
            max_size = DEFAULT_MAX_THUMBNAIL_SIZE

        # attempt to open the file as an image
        image_buffer = None
        try:
            image = Upload_file.open_image(file_id)

            # scale the image down into a thumbnail
            image.thumbnail((max_size, max_size), Image.ANTIALIAS)

            # save the image into a memory buffer
            image_buffer = StringIO()
            image.save(image_buffer, "PNG")
            image_buffer.seek(0)
        except IOError:
            image = Image.open("static/images/default_thumbnail.png")
            image_buffer = StringIO()
            image.save(image_buffer, "PNG")
            image_buffer.seek(0)

        return image_buffer.getvalue()

    @expose()
    @weakly_expire
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def image(self, file_id, user_id=None):
        """
    Return the contents of an image file that a user has previously uploaded. This is distinct
    from the download() method above in that it doesn't set HTTP headers for a file download.

    @type file_id: unicode
    @param file_id: id of the file to return
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: generator
    @return: image data
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)

        if not db_file or not self.__users.load_notebook(
                user_id, db_file.notebook_id):
            raise Access_error()

        cherrypy.response.headerMap[u"Content-Type"] = db_file.content_type

        if self.__web_server == u"nginx":
            cherrypy.response.headerMap[
                u"X-Accel-Redirect"] = "/download/%s" % file_id
            return ""

        def stream():
            CHUNK_SIZE = 8192
            local_file = Upload_file.open_file(file_id)
            local_file.seek(0)

            while True:
                data = local_file.read(CHUNK_SIZE)
                if len(data) == 0: break
                yield data

        return stream()

    @expose(view=Json)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        notebook_id=Valid_id(),
        note_id=Valid_id(none_okay=True),
        user_id=Valid_id(none_okay=True),
    )
    def upload_id(self, notebook_id, note_id, user_id):
        """
    Generate and return a unique file id for use in an upload.

    @type notebook_id: unicode
    @param notebook_id: id of the notebook that the upload will be to
    @type note_id: unicode
    @param note_id: id of the note that the upload will be to
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: unicode
    @return: { 'file_id': file_id }
    @raise Access_error: the current user doesn't have access to the given notebook
    """
        notebook = self.__users.load_notebook(user_id,
                                              notebook_id,
                                              read_write=True,
                                              note_id=note_id)

        if not notebook or notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
            raise Access_error()

        file_id = self.__database.next_id(File)

        return dict(file_id=file_id, )

    @expose(view=Blank_page)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        upload=(),
        notebook_id=Valid_id(),
        note_id=Valid_id(none_okay=True),
        x_progress_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def upload(self, upload, notebook_id, note_id, x_progress_id, user_id):
        """
    Upload a file from the client for attachment to a particular note. The x_progress_id must be
    provided as part of the query string, even if the other values are submitted as form data.

    @type upload: cgi.FieldStorage
    @param upload: file handle to uploaded file
    @type notebook_id: unicode
    @param notebook_id: id of the notebook that the upload is to
    @type note_id: unicode or NoneType
    @param note_id: id of the note that the upload is to (if any)
    @type x_progess_id: unicode
    @param x_progess_id: id of the file being uploaded
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: unicode
    @return: rendered HTML page
    @raise Access_error: the current user doesn't have access to the given notebook or note
    @raise Upload_error: the Content-Length header value is invalid
    """
        global current_uploads, current_uploads_lock
        file_id = x_progress_id

        current_uploads_lock.acquire()
        try:
            uploaded_file = current_uploads.get(file_id)
            if not uploaded_file:
                return dict(script=general_error_script %
                            u"Please select a file to upload.")

            del (current_uploads[file_id])
        finally:
            current_uploads_lock.release()

        user = self.__database.load(User, user_id)
        notebook = self.__users.load_notebook(user_id,
                                              notebook_id,
                                              read_write=True)

        if not user or not notebook or notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
            uploaded_file.delete()
            return dict(
                script=general_error_script %
                u"Sorry, you don't have access to do that. Please make sure you're logged in as the correct user."
            )

        content_type = upload.headers.get("content-type")

        # if we didn't receive all of the expected data, abort
        if uploaded_file.total_received_bytes < uploaded_file.content_length:
            uploaded_file.delete()
            return dict(
                script=general_error_script %
                u"The uploaded file was not fully received. Please try again or contact support."
            )

        if uploaded_file.file_received_bytes == 0:
            uploaded_file.delete()
            return dict(
                script=general_error_script %
                u"The uploaded file was not received. Please make sure that the file exists."
            )

        # if the uploaded file's size would put the user over quota, bail and inform the user
        rate_plan = self.__users.rate_plan(user.rate_plan)
        storage_quota_bytes = rate_plan.get(u"storage_quota_bytes")

        if storage_quota_bytes and user.storage_bytes + uploaded_file.total_received_bytes > storage_quota_bytes:
            uploaded_file.delete()
            return dict(script=quota_error_script)

        # record metadata on the upload in the database
        db_file = File.create(file_id, notebook_id, note_id,
                              uploaded_file.filename,
                              uploaded_file.file_received_bytes, content_type)
        self.__database.save(db_file, commit=False)
        self.__users.update_storage(user_id, commit=False)
        self.__database.commit()
        uploaded_file.close()

        return dict()

    @expose(view=Json)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        x_progress_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def progress(self, x_progress_id, user_id=None):
        """
    Return information on a file that is in the process of being uploaded. This method does not
    perform any access checks, but the only information revealed is the file's upload progress.

    This method is intended to be polled while the file is uploading, and its returned data is
    intended to mimic the API described here:
    http://wiki.nginx.org//NginxHttpUploadProgressModule

    @type x_progress_id: unicode
    @param x_progress_id: id of a currently uploading file
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: dict
    @return: one of the following:
      { 'state': 'starting' }                          // file_id is unknown
      { 'state': 'done' }                              // upload is complete
      { 'state': 'error', 'status': http_error_code }  // upload generated an HTTP error
      { 'state': 'uploading',                          // upload is in progress
        'received': bytes_received, 'size': total_bytes }
    """
        global current_uploads
        file_id = x_progress_id

        uploading_file = current_uploads.get(file_id)
        db_file = None

        user = self.__database.load(User, user_id)
        if not user:
            return dict(
                state="error",
                status=httplib.FORBIDDEN,
            )

        if uploading_file:
            # if the uploaded file's size would put the user over quota, bail and inform the user
            SOFT_QUOTA_FACTOR = 1.05  # fudge factor since content_length isn't really the file's actual size

            rate_plan = self.__users.rate_plan(user.rate_plan)

            storage_quota_bytes = rate_plan.get(u"storage_quota_bytes")
            if storage_quota_bytes and \
               user.storage_bytes + uploading_file.content_length > storage_quota_bytes * SOFT_QUOTA_FACTOR:
                return dict(
                    state="error",
                    status=httplib.REQUEST_ENTITY_TOO_LARGE,
                )

            return dict(
                state=u"uploading",
                received=uploading_file.total_received_bytes,
                size=uploading_file.content_length,
            )

        db_file = self.__database.load(File, file_id)
        if not db_file:
            return dict(
                state="error",
                status=httplib.NOT_FOUND,
            )

        if db_file.filename is None:
            return dict(state=u"starting")

        # the file is completely uploaded (in the database with a filename)
        return dict(state=u"done")

    @expose(view=Json)
    @strongly_expire
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def stats(self, file_id, user_id=None):
        """
    Return information on a file that has been completely uploaded with its metadata stored in the
    database. Also return the user's current storage utilization in bytes.

    @type file_id: unicode
    @param file_id: id of the file to report on
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: dict
    @return: {
      'filename': filename,
      'size_bytes': filesize,
      'storage_bytes': current storage usage by user
    }
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)
        if db_file is None:
            raise Access_error()

        db_notebook = self.__users.load_notebook(user_id, db_file.notebook_id)
        if db_notebook is None or db_notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
            raise Access_error()

        user = self.__database.load(User, user_id)
        if not user:
            raise Access_error()

        user.group_storage_bytes = self.__users.calculate_group_storage(user)

        return dict(
            filename=db_file.filename,
            size_bytes=db_file.size_bytes,
            storage_bytes=user.storage_bytes,
        )

    @expose(view=Json)
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def delete(self, file_id, user_id=None):
        """
    Delete a file that has been completely uploaded, removing both its metadata from the database
    and its data from the filesystem. Return the user's current storage utilization in bytes.

    @type file_id: unicode
    @param file_id: id of the file to delete
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: dict
    @return: {
      'storage_bytes': current storage usage by user
    }
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)
        if db_file is None:
            raise Access_error()

        db_notebook = self.__users.load_notebook(user_id,
                                                 db_file.notebook_id,
                                                 read_write=True)
        if db_notebook is None or db_notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
            raise Access_error()

        self.__database.execute(db_file.sql_delete(), commit=False)
        user = self.__users.update_storage(user_id, commit=False)
        self.__database.uncache(db_file)
        self.__database.commit()
        user.group_storage_bytes = self.__users.calculate_group_storage(user)

        Upload_file.delete_file(file_id)

        return dict(storage_bytes=user.storage_bytes, )

    @expose(view=Json)
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        filename=unicode,
        user_id=Valid_id(none_okay=True),
    )
    def rename(self, file_id, filename, user_id=None):
        """
    Rename a file that has been completely uploaded.

    @type file_id: unicode
    @param file_id: id of the file to delete
    @type filename: unicode
    @param filename: new name for the file
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: dict
    @return: {}
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    """
        db_file = self.__database.load(File, file_id)
        if db_file is None:
            raise Access_error()

        db_notebook = self.__users.load_notebook(user_id,
                                                 db_file.notebook_id,
                                                 read_write=True)
        if db_notebook is None or db_notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
            raise Access_error()

        db_file.filename = filename
        self.__database.save(db_file)

        return dict()

    def parse_csv(self, file_id, skip_header=False):
        """
    Attempt to parse a previously uploaded file as a table or spreadsheet. Generate rows as they're
    requested.

    @type file_id: unicode
    @param file_id: id of the file to parse
    @type skip_header: bool
    @param skip_header: if a line of header labels is detected, don't include it in the generated
                        rows (defaults to False)
    @rtype: generator
    @return: rows of data from the parsed file. each row is a list of elements
    @raise Parse_error: there was an error in parsing the given file
    """
        APPROX_SNIFF_SAMPLE_SIZE_BYTES = 1024 * 50

        try:
            import csv

            table_file = Upload_file.open_file(file_id)
            table_file.seek(
                0
            )  # necessary in case the file is opened by another call to parse_csv()
            sniffer = csv.Sniffer()

            # attempt to determine the presence of a header
            lines = table_file.readlines(APPROX_SNIFF_SAMPLE_SIZE_BYTES)
            sniff_sample = "".join(lines)

            has_header = sniffer.has_header(sniff_sample)

            # attempt to determine the file's character encoding
            detector = UniversalDetector()
            for line in lines:
                detector.feed(line)
                if detector.done: break

            detector.close()
            encoding = detector.result.get("encoding")

            table_file.seek(0)
            reader = csv.reader(table_file)

            # skip the header if requested to do so
            if has_header and skip_header:
                reader.next()

            expected_row_length = None

            for row in reader:
                # all rows must have the same number of elements
                current_row_length = len(row)
                if current_row_length == 0:
                    continue

                if expected_row_length and current_row_length != expected_row_length:
                    raise Parse_error()
                else:
                    expected_row_length = current_row_length

                yield [element.decode(encoding) for element in row]
        except (csv.Error, IOError, TypeError):
            raise Parse_error()

    @expose(view=Json)
    @end_transaction
    @grab_user_id
    @validate(
        file_id=Valid_id(),
        user_id=Valid_id(none_okay=True),
    )
    def csv_head(self, file_id, user_id=None):
        """
    Attempt to parse a previously uploaded file as a table or spreadsheet. Return the first few rows
    of that table, with each element truncated to a maximum length if necessary.

    Currently, only a CSV file format is supported.

    @type file_id: unicode
    @param file_id: id of the file to parse
    @type user_id: unicode or NoneType
    @param user_id: id of current logged-in user (if any)
    @rtype: dict
    @return: {
      'file_id': file id,
      'rows': list of parsed rows, each of which is a list of elements,
    }
    @raise Access_error: the current user doesn't have access to the notebook that the file is in
    @raise Parse_error: there was an error in parsing the given file
    """
        MAX_ROW_COUNT = 4
        MAX_ELEMENT_LENGTH = 30
        MAX_ROW_ELEMENT_COUNT = 20

        db_file = self.__database.load(File, file_id)
        if db_file is None:
            raise Access_error()

        db_notebook = self.__users.load_notebook(user_id, db_file.notebook_id)
        if db_notebook is None or db_notebook.read_write == Notebook.READ_WRITE_FOR_OWN_NOTES:
            raise Access_error()

        parser = self.parse_csv(file_id)
        rows = []

        def truncate(element):
            if len(element) > MAX_ELEMENT_LENGTH:
                return "%s ..." % element[:MAX_ELEMENT_LENGTH]

            return element

        for row in parser:
            if len(row) == 0:
                continue

            rows.append([truncate(element)
                         for element in row][:MAX_ROW_ELEMENT_COUNT])
            if len(rows) == MAX_ROW_COUNT:
                break

        if len(rows) == 0:
            raise Parse_error()

        return dict(
            file_id=file_id,
            rows=rows,
        )

    def purge_unused(self, note, purge_all_links=False):
        """
    Delete files that were linked from the given note but no longer are.

    @type note: model.Note
    @param note: note to search for file links
    @type purge_all_links: bool
    @param purge_all_links: if True, delete all files that are/were linked from this note
    """
        # load metadata for all files with the given note's note_id
        files = self.__database.select_many(
            File, File.sql_load_note_files(note.object_id))
        files_to_delete = dict([(db_file.object_id, db_file)
                                for db_file in files])

        # search through the note's contents for current links to files
        if purge_all_links is False:
            for match in self.FILE_LINK_PATTERN.finditer(note.contents):
                file_id = match.groups(0)[0]

                # we've found a link for file_id, so don't delete that file
                files_to_delete.pop(file_id, None)

        # for each file to delete, delete its metadata from the database and its data from the
        # filesystem
        for (file_id, db_file) in files_to_delete.items():
            self.__database.execute(db_file.sql_delete(), commit=False)
            self.__database.uncache(db_file)
            Upload_file.delete_file(file_id)

        self.__database.commit()