#!/usr/bin/env python2.5 """ Web-based bookmarks manager, like the old del.icio.us interface. Adam Sampson This is a module. To use it, write a little stub CGI script that invokes "run" with the appropriate parameters: #!/usr/bin/env python2.5 import sys sys.path.append("/home/me/misccode") # Directory with the module in import tasty tasty.run("/home/me/tasty.db", # SQLite database location "http://example.org/tasty") # Base URL You can run the stub script from the command line to add and remove users, and import existing bookmarks; run it with "help" as an option to see what options it'll accept. """ import cgi, Cookie, sys, os, re, sqlite3, xml.dom.minidom, datetime, urllib, md5 import codecs import cgitb; cgitb.enable() def db_exists(source): """Return whether the database has been created.""" return os.access(source, os.F_OK) def open_db(source): """Open the database. Change this to use a different database engine.""" return sqlite3.connect(source, isolation_level = 'DEFERRED') # SQL statements to execute to set up the database. Change this to use a # different database engine. INIT_SQL = [ """CREATE TABLE users ( username TEXT NOT NULL, password TEXT, realname TEXT, PRIMARY KEY (username) )""", """CREATE TABLE bookmarks ( url TEXT NOT NULL, username TEXT NOT NULL /* FOREIGN KEY REFERENCES users(username) */, added TEXT, private INTEGER, description TEXT, notes TEXT, PRIMARY KEY (url, username) )""", """CREATE TABLE tags ( url TEXT NOT NULL /* FOREIGN KEY REFERENCES bookmarks(url) */, username TEXT NOT NULL /* FOREIGN KEY REFERENCES users(username) */, tag TEXT NOT NULL, PRIMARY KEY (url, username, tag) )""", """CREATE TABLE authcookies ( username TEXT NOT NULL /* FOREIGN KEY REFERENCES users(username) */, host TEXT NOT NULL, added TEXT, cookie TEXT, PRIMARY KEY (username, host, cookie) )""", ] # The stylesheet for the web interface. CSS = """ /* This layout is based on: http://matthewjamestaylor.com/blog/ultimate-2-column-right-menu-ems.htm */ BODY { background-color: white; color: black; font-size: medium; margin: 0; padding: 0; border: 0; width: 100%; } A:link { color: #22d; } A:visited { color: #848; } #header { clear: both; float: left; width: 100%; border-bottom: 1px solid #AAA; background: #EEEEEE; } H1 { font-size: large; padding: 0.5em; margin: 0; } .onecolumn { position: relative; clear: both; float: left; width: 100%; } .colmask { position: relative; clear: both; float: left; width: 100%; overflow: hidden; } .rightmenu { background: #EEEEEE; } .rightmenu .colleft { float: left; width: 200%; margin-left: -17em; position: relative; right: 100%; background: #fff; } .rightmenu .col1wrap { float: left; width: 50%; position: relative; left: 50%; padding-bottom: 1em; } .rightmenu .col1 { margin: 0 1em 0 18em; overflow: hidden; } .rightmenu .col2 { float: right; width: 15em; position: relative; left: 16em; } #footer { clear: both; float: left; width: 100%; border-top: 1px solid #000; } #footer P { padding: 0.5em; } .actionbox { margin: 2em 0; padding: 0 1em; background-color: #EEE; border: 1px solid #BBB; } .abbrowser, .abmisc { padding-left: 2em; } .bookmark { margin: 1em 0; } .bookmark P { margin: 0; padding: 0 0 1px 0; } .bmshade { color: PALE-TEXT; } .bmshade A:link { color: PALE-LINK; } .bmshade A:visited { color: PALE-VISITED; } .bmtitle { margin-right: 0.5em; } .navnp { color: PALE-TEXT; } .navpage { padding-left: 1.5em; } .taglist { margin: 0; padding: 0; } .tlcount { text-align: right; } .taglist TR { margin: 0; padding: 0; } .taglist TD { border: none; margin: 0; padding: 0 3px; } .tlheader { font-size: medium; font-weight: bold; } .formtable TH { font-weight: normal; text-align: right; } """.replace("PALE-TEXT", "#999").replace("PALE-LINK", "#99d").replace("PALE-VISITED", "#d99") # The time format used in the database, in XML dumps, and in Atom feeds. # This is the simple UTC format from RFC 3339. TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" def parse_time(s): return datetime.datetime.strptime(s, TIME_FORMAT) def format_time(dt): return dt.strftime(TIME_FORMAT) def nice_time(dt): """Produce a human-readable timestamp.""" now = datetime.datetime.utcnow() age = now - dt TD = datetime.timedelta if age < TD(): return "in the future" elif age < TD(minutes = 1): return "just now" elif age < TD(hours = 1): return "%d minutes ago" % (age.seconds / 60) elif age < TD(days = 1): return "%d hours ago" % (age.seconds / (60 * 60)) elif age < TD(days = 7): return "%d days ago" % age.days elif dt.year == now.year: return dt.strftime("on %d %B") else: return dt.strftime("on %d %B %Y") def escape_attr(s): return cgi.escape(s, True) def escape_text(s): return cgi.escape(s, False) def escape_query(s): return urllib.quote_plus(s) def format_link(s, url): if url is None: return s else: return '%s' % (escape_attr(url), s) bad_tag_char_re = re.compile(r'[^a-zA-Z0-9.+_-]') def parse_tags(s): """Split a tag string (e.g. "foo bar baz") into a list of valid tags.""" tags = [] for tag in s.strip().split(): tag = bad_tag_char_re.sub('', tag) if tag != "": tags.append(tag) return tags class UnicodeFormWrapper: """Wrap a form, decoding values entered into Unicode strings.""" def __init__(self, form): self.form = form def keys(self): return self.form.keys() def getfirst(self, key, value = None): return self.decode(self.form.getfirst(key, value)) def getlist(self, key): return map(self.decode, self.form.getlist(key)) def decode(self, s): if s is None: return s elif type(s) is unicode: return s else: try: return s.decode("UTF-8") except UnicodeDecodeError: # Best efforts: it's not valid UTF-8, so just # use the characters as-is. return s.decode("ISO-8859-1") class Query: """A collection of bookmarks in the database. The context is a list of tags.""" def __init__(self, tasty, username, context): self.tasty = tasty self.username = username self.context = context self.c = tasty.conn.cursor() wheres = [] args = [] for tag in context: wheres.append("EXISTS (SELECT t.tag FROM tags t WHERE t.url = b.url AND t.username = b.username AND t.tag = ?)") args.append(tag) if username is not None: wheres.append("b.username = ?") args.append(username) if tasty.auth_username is None or tasty.auth_username != username: wheres.append("b.private = 0") if wheres != []: where = " WHERE " + " AND ".join(wheres) else: where = "" self.c.execute("SELECT b.url, b.username, b.added, b.private, b.description, b.notes FROM bookmarks b%s ORDER BY b.added DESC" % where, args) self.c2 = tasty.conn.cursor() self.realname = tasty.realname(username) def title(self): """Get a human-readable title for this collection.""" title = "bookmarks" if self.context != []: title = escape_text(" + ".join(self.context)) + " " + title return self.realname + "'s " + title def url(self, format = "html", start = 0): """Get the URL for the view page corresponding to this set.""" return self.tasty.url(self.username, self.context, format, start) def fetch(self, skip = False): """Retrieve the next bookmark. Returns None if there aren't any more. If skip is True, return True rather than the bookmark (avoiding an extra database lookup for the tags).""" row = self.c.fetchone() if row is None: return None if skip: return True (url, username, added, private, description, notes) = row added = parse_time(added) private = (private != 0) self.c2.execute("SELECT tag FROM tags WHERE url = ? AND username = ?", (url, username)) tags = [r[0] for r in self.c2.fetchall()] return (url, username, added, private, description, notes, tags) def __iter__(self): # This class is its own iterator. return self def next(self): row = self.fetch() if row is None: raise StopIteration() else: return row class Tasty: def __init__(self, db_source, base_url): self.out_file = sys.stdout self.out = lambda s: self.out_file.write(s.encode("UTF-8")) if not db_exists(db_source): print >>sys.stderr, "Database does not exist; creating it" conn = open_db(db_source) c = conn.cursor() for s in INIT_SQL: c.execute(s) conn.commit() c.close() conn.close() self.conn = open_db(db_source) self.base_url = base_url self.auth_username = None self.realname_cache = {} def realname(self, username): """Get the real name of a user, caching the result.""" if username is None: return "Everybody" if username not in self.realname_cache: c = self.conn.cursor() c.execute("SELECT realname FROM users WHERE username = ?", (username,)) row = c.fetchone() if row is None: self.error("User does not exist", "404 Not Found") self.realname_cache[username] = row[0] return self.realname_cache[username] def delete_bookmark(self, c, url): """Delete a bookmark from the database.""" c.execute("DELETE FROM bookmarks WHERE url = ? AND username = ?", (url, self.auth_username)) c.execute("DELETE FROM tags WHERE url = ? AND username = ?", (url, self.auth_username)) def update_bookmark(self, c, oldurl, url, added, private, description, notes, tags): """Update a bookmark. This works by deleting the original entry then inserting a new one, so it can be used to change a bookmark's URL too.""" self.delete_bookmark(c, oldurl) c.execute("INSERT INTO bookmarks (url, username, added, private, description, notes) VALUES (?, ?, ?, ?, ?, ?)", (url, self.auth_username, format_time(added), private, description, notes)) for tag in tags: c.execute("INSERT INTO tags (url, username, tag) VALUES (?, ?, ?)", (url, self.auth_username, tag)) def update_bookmark_commit(self, *args): """Update a bookmark and commit the changes.""" c = self.conn.cursor() self.update_bookmark(c, *args) self.conn.commit() c.close() def try_auth(self, form): """Try to authenticate the user, either through a username and password they've provided or through a cookie. Since this may set cookies, it must be run before headers have been printed.""" self.auth_username = None now = datetime.datetime.utcnow() def expire(c): c.execute("DELETE FROM authcookies WHERE added < ?", (format_time(now - datetime.timedelta(days = 7)),)) self.conn.commit() host = os.getenv("REMOTE_ADDR") username = form.getfirst("username", None) password = form.getfirst("password", None) if username is not None and password is not None: c = self.conn.cursor() c.execute("SELECT username FROM users WHERE username = ? AND password = ?", (username, md5.new(password).hexdigest())) if c.fetchone() is None: self.error("Bad username or password") cookie = md5.new(os.urandom(32)).hexdigest() expire(c) c.execute("INSERT INTO authcookies (username, host, added, cookie) VALUES (?, ?, ?, ?)", (username, host, format_time(now), cookie)) self.conn.commit() c.close() cookies = Cookie.SimpleCookie() cookies["tasty_username"] = username cookies["tasty_cookie"] = cookie self.out(cookies.output(sep = "\n") + "\n") self.auth_username = username return cookies = Cookie.SimpleCookie(os.environ.get("HTTP_COOKIE", "")) if "tasty_username" in cookies and "tasty_cookie" in cookies: username = cookies["tasty_username"].value cookie = cookies["tasty_cookie"].value c = self.conn.cursor() expire(c) c.execute("SELECT cookie FROM authcookies WHERE username = ? AND host = ? AND cookie = ?", (username, host, cookie)) if c.fetchone() is not None: self.auth_username = username c.close() return c.close() def remove_auth(self, form): """Delete the user's current authentication information.""" if self.auth_username is None: return cookies = Cookie.SimpleCookie(os.environ.get("HTTP_COOKIE", "")) if not "tasty_cookie" in cookies: return cookie = cookies["tasty_cookie"].value c = self.conn.cursor() c.execute("DELETE FROM authcookies WHERE username = ? AND cookie = ?", (self.auth_username, cookie)) self.conn.commit() def require_auth(self, form): """Check that the user has authenticated. If not, display a login form and exit.""" if self.auth_username is not None: return self.header("Log in") url = self.base_url + os.getenv("PATH_INFO") self.out('''
Username
Password

\n''') # Pass through all the parameters we already have. for key in form.keys(): for value in form.getlist(key): self.out('''\n''') self.out('''

\n''') self.footer() sys.exit(0) def import_file(self, f): dom = xml.dom.minidom.parse(f) posts = dom.getElementsByTagName("posts")[0] for post in posts.getElementsByTagName("post"): self.import_post(post) def import_post(self, post): url = post.getAttribute("href") description = post.getAttribute("description") tags = parse_tags(post.getAttribute("tag")) added = parse_time(post.getAttribute("time")) notes = post.getAttribute("extended") if post.getAttribute("shared") == "no": private = 1 else: private = 0 self.update_bookmark_commit(url, url, added, private, description, notes, tags) def export(self, username, context): query = Query(self, username, context) dom = xml.dom.minidom.Document() posts = dom.createElement("posts") dom.appendChild(posts) posts.setAttribute("user", username) posts.setAttribute("tag", " ".join(context)) first = True count = 0 for (url, username, added, private, description, notes, tags) in query: if first: posts.setAttribute("update", format_time(added)) first = False count += 1 post = dom.createElement("post") posts.appendChild(post) post.setAttribute("href", url) post.setAttribute("description", description) post.setAttribute("tag", " ".join(tags)) post.setAttribute("time", format_time(added)) post.setAttribute("extended", notes) if private: post.setAttribute("shared", "no") # Some additional fields for compatibility with # del.icio.us. post.setAttribute("hash", md5.new(url).hexdigest()) post.setAttribute("others", "-1") posts.setAttribute("total", str(count)) self.write_xml(dom) def feed(self, username, context, min_count = 10, max_age = datetime.timedelta(days = 14)): query = Query(self, username, context) dom = xml.dom.minidom.Document() def add_text(parent, name, value): e = dom.createElement(name) e.appendChild(dom.createTextNode(value)) parent.appendChild(e) return e def add_link(parent, name, url): e = dom.createElement(name) e.setAttribute("href", url) parent.appendChild(e) return e def add_author(parent, realname): e = dom.createElement("author") add_text(e, "name", realname) parent.appendChild(e) return e feed = dom.createElement("feed") feed.setAttribute("xmlns", "http://www.w3.org/2005/Atom") dom.appendChild(feed) add_text(feed, "title", query.title()) add_link(feed, "link", query.url()) l = add_link(feed, "link", query.url(format = "atom")) l.setAttribute("rel", "self") add_text(feed, "id", query.url()) if username is not None: add_author(feed, query.realname) first = True count = 0 now = datetime.datetime.utcnow() for (url, user, added, private, description, notes, tags) in query: if first: add_text(feed, "updated", format_time(added)) first = False if count > min_count and now - added > max_age: break count += 1 entry = dom.createElement("entry") feed.appendChild(entry) add_text(entry, "title", description) add_link(entry, "link", url) id = self.base_url + "/edit?url=" + escape_query(url) add_text(entry, "id", id) add_text(entry, "updated", format_time(added)) if notes == "": notes = "(No notes.)" add_text(entry, "content", notes) if username is None: add_author(entry, self.realname(user)) self.write_xml(dom, "application/atom+xml") def write_xml(self, dom, type = "text/xml"): """Write a minidom XML tree as an HTTP response.""" self.out("Content-type: %s; charset=UTF-8\n\n" % type) writer = codecs.getwriter("UTF-8")(self.out_file) dom.writexml(writer, encoding = "UTF-8") dom.unlink() def redirect(self, url): """Produce a redirect HTTP response.""" self.out("Location: %s\n\n" % url) def error(self, message, status = '500 Internal Server Error'): """Produce an error HTTP response.""" self.out("Status: %s\n" % status) self.header("Error") self.out("

%s

\n" % message) self.footer() sys.exit(0) def header(self, title, links = [], robots = "NOINDEX,NOFOLLOW", columns = 1): """Start an HTML page.""" links = links + [("stylesheet", self.base_url + "/css", "text/css")] ctype = "text/html; charset=UTF-8" self.out("Content-type: %s\n\n" % ctype) self.out(''' \n''') for (rel, href, type) in links: if type is None: type = "" else: type = ' type="%s"' % escape_attr(type) self.out('\n' % (escape_attr(rel), escape_attr(href), type)) self.out('''''' + title + ''' - Tasty \n''') self.columns = columns if columns == 2: self.out('''
\n''') else: self.out('''
\n''') def flip(self): """Switch from the first column of output to the second.""" assert self.columns == 2 self.out('''
\n''') def footer(self): """End an HTML page.""" if self.columns == 2: self.out('''
\n''') else: self.out('''
\n''') self.out(''' \n''') def get_referer(self, form, url): backto = form.getfirst("backto") if backto is None: referer = os.environ.get("HTTP_REFERER") if referer is None: return self.home() else: return referer elif backto == "url": return url else: return backto def edit_page(self, form): url = form.getfirst("url") if url is None: self.error("No URL specified") c = self.conn.cursor() c.execute("SELECT added, private, description, notes FROM bookmarks WHERE url = ? AND username = ?", (url, self.auth_username)) row = c.fetchone() if row is None: added = datetime.datetime.utcnow() try: private = int(form.getfirst("private", "0")) except ValueError: private = 0 description = form.getfirst("description", "") notes = form.getfirst("notes", "") else: (added, private, description, notes) = row added = parse_time(added) private = int(private) if private: private_checked = ' checked="checked"' else: private_checked = '' c.execute("SELECT tag FROM tags WHERE url = ? AND username = ?", (url, self.auth_username)) tags = [r[0] for r in c.fetchall()] referer = self.get_referer(form, url) delete_url = "%s/delete?url=%s&backto=%s" % (self.base_url, escape_query(url), escape_query(self.home())) self.header("Edit bookmark") self.out('''
URL
Private
Description
Notes
Tags

\n''') if row is not None: self.out(''' Originally posted ''' + escape_text(nice_time(added)) + '''. Delete this bookmark. \n''') self.out('''

\n''') self.footer() def edited_page(self, form): referer = form.getfirst("referer") oldurl = form.getfirst("oldurl") url = form.getfirst("url") added = form.getfirst("added") private = form.getfirst("private") description = form.getfirst("description") notes = form.getfirst("notes") tags = form.getfirst("tags") if oldurl is None: oldurl = url if url is None or added is None or description is None: self.error("Edit form incomplete: must have at least URL and description") if notes is None: notes = "" if tags is None: tags = [] else: tags = parse_tags(tags) added = parse_time(added) if private is None: private = 0 else: private = 1 if referer is None: referer = self.home() self.update_bookmark_commit(oldurl, url, added, private, description, notes, tags) self.redirect(referer) def delete_page(self, form): url = form.getfirst("url") if url is None: self.error("No URL specified") referer = self.get_referer(form, url) self.header("Delete bookmark") self.out('''

Really delete bookmark ''' + escape_text(url) + '''?

\n''') self.footer() def deleted_page(self, form): url = form.getfirst("url") referer = form.getfirst("referer") cancel = form.getfirst("cancel") if cancel is None: c = self.conn.cursor() self.delete_bookmark(c, url) self.conn.commit() c.close() self.redirect(referer) def robots_txt(self): self.out("Content-type: text/plain\n\n") self.out("User-agent: *\n") # Disallow special URLs. self.out("Disallow: /edit\n") self.out("Disallow: /delete\n") self.out("Disallow: /login\n") self.out("Disallow: /logout\n") # Disallow /all entirely -- it's all duplicate content. self.out("Disallow: /all\n") # Disallow any combination of tags. c = self.conn.cursor() c.execute("SELECT DISTINCT username, tag FROM tags ORDER BY username, tag") for row in c.fetchall(): self.out("Disallow: /" + row[0] + "/" + row[1] + "+\n") c.close() def url(self, username = None, context = [], format = "html", start = 0): """Get the URL for the view page for a context.""" url = self.base_url + "/" if username is None: if context != []: url += "all/" else: url += username + "/" url += "+".join(context) query = [] if format != "html": query.append("format=" + format) if start != 0: query.append("start=" + str(start)) if query != []: url += "?" + "&".join(query) return url def home(self): """Get the main view page for the logged-in user.""" return self.url(self.auth_username) def format_tag(self, tag, username, context = [], remove = False): if remove: tags = context + [] tags.remove(tag) else: tags = context + [tag] url = self.url(username, tags) if context == []: sign = "" elif remove: sign = "- " else: sign = "+ " return format_link(sign + escape_text(tag), url) def view_page(self, username, context, form, count = 10): try: start = int(form.getfirst("start", "0")) if start < 0: start = 0 except ValueError: start = 0 query = Query(self, username, context) total = 0 results = [] while True: if total >= start and total < (start + count): row = query.fetch() if row is None: break results.append(row) else: row = query.fetch(True) if row is None: break total += 1 links = [("alternate", query.url(format = "atom"), "application/atom+xml")] if start > 0: next_url = query.url(start = max(0, start - count)) links.append(("next", next_url, None)) start_url = query.url() links.append(("start", start_url, None)) else: next_url = None if start < total - count: prev_url = query.url(start = min(total - count, start + count)) links.append(("prev", prev_url, None)) else: prev_url = None # I want search engines to index the main page for each user, # and the page for each tag -- but not to index every # combination of tags. # It turns out that Googlebot at least pretty much ignores # this, and winds up crawling every combination of tags anyway, # hence the robots.txt... if username is None: robots = "NOINDEX,FOLLOW" elif context == []: robots = "INDEX,FOLLOW" elif len(context) == 1: robots = "INDEX,NOFOLLOW" else: robots = "NOINDEX,NOFOLLOW" self.header(query.title(), links, robots, columns = 2) def actionbox(): smart_bookmark = "javascript:location.href='" + self.base_url + "/edit?backto=url&url='+encodeURIComponent(location.href)+'&description='+encodeURIComponent(document.title)" self.out('''

Browser bookmarks: tasty+ | tasty= \n''') if self.auth_username is not None: self.out('''Log out''') else: self.out('''Log in''') self.out(''' | Export | Atom

\n''') def nav(): self.out('\n') nav() self.out('
\n') for (url, user, added, private, description, notes, tags) in results: if self.auth_username is None or self.auth_username == user: edit_url = self.base_url + "/edit?url=" + escape_query(url) delete_url = self.base_url + "/delete?url=" + escape_query(url) actions_html = ''' ''' + format_link("edit", edit_url) + ''' / ''' + format_link("delete", delete_url) + ''' ''' else: actions_html = "" added_html = nice_time(added) by_html = "" if username is None: by_html = "by " + format_link(escape_text(user), self.url(user)) + " ..." private_html = "" if private: private_html = "private ..." tags_html = " ".join([self.format_tag(tag, user) for tag in tags]) self.out('''

''' + escape_text(description) + ''' ''' + actions_html + '''

''' + escape_text(notes) + '''

''' + by_html + ''' to ''' + tags_html + ''' ... ''' + private_html + ''' ''' + added_html + '''

\n''') self.out('
\n') nav() actionbox() self.flip() c = self.conn.cursor() if username is None: self.out('''

All users

\n''') c.execute("SELECT username FROM users ORDER BY username") for row in c.fetchall(): user = row[0] self.out('''\n''') self.out('''
''' + format_link(escape_text(user), self.url(user)) + '''
\n''') self.out('
\n') wheres = [] tables = [] args = [] if context == []: heading = "All tags" else: heading = "Tags related to " + escape_text(" + ".join(context)) i = 0 for tag in context: tables.append("tags t%d" % i) wheres.append("t.url = t%d.url" % i) wheres.append("t%d.tag = ?" % i) args.append(tag) if username is not None: wheres.append("t%d.username = ?" % i) args.append(username) i += 1 if username is not None: wheres.append("t.username = ?") args.append(username) extra = "".join([", " + t for t in tables]) if wheres != []: extra += " WHERE " + " AND ".join(wheres) c.execute("SELECT t.tag, COUNT(*) FROM tags t%s GROUP BY t.tag ORDER BY t.tag" % extra, args) self.out('

%s

\n' % heading) self.out('\n') def row(count, tag): self.out('''\n''') if context != []: for tag in context: row("", self.format_tag(tag, username, context, remove = True)) # XXX: do this with CSS instead! row(" ", "") while True: r = c.fetchone() if r is None: break (tag, count) = r if tag in context: continue row(str(count), self.format_tag(tag, username, context)) c.close() self.out('''
''' + count + ''' ''' + tag + '''
\n''') self.footer() def main(self): self.auth_username = None if len(sys.argv) == 5 and sys.argv[1] == "adduser": c = self.conn.cursor() c.execute("DELETE FROM users WHERE username = ?", (sys.argv[2],)) c.execute("INSERT INTO users (username, password, realname) VALUES (?, ?, ?)", (sys.argv[2], sys.argv[3], sys.argv[4])) self.conn.commit() c.close() return elif len(sys.argv) == 3 and sys.argv[1] == "deluser": c = self.conn.cursor() c.execute("DELETE FROM users WHERE username = ?", (sys.argv[2],)) c.execute("DELETE FROM bookmarks WHERE username = ?", (sys.argv[2],)) c.execute("DELETE FROM tags WHERE username = ?", (sys.argv[2],)) c.execute("DELETE FROM authcookies WHERE username = ?", (sys.argv[2],)) self.conn.commit() c.close() return elif len(sys.argv) == 4 and sys.argv[1] == "import": self.auth_username = sys.argv[2] f = open(sys.argv[3]) self.import_file(f) f.close() return elif len(sys.argv) != 1: prog = sys.argv[0] print '''tasty - a web-based bookmarks manager Usage: ''' + prog + ''' Run as a CGI script. ''' + prog + ''' adduser USERNAME MD5-PASSWORD REAL-NAME Add a new user, or change the details of an existing one. ''' + prog + ''' deluser USERNAME Delete a user and all their bookmarks. ''' + prog + ''' import USERNAME FILE Import bookmarks from an XML dump. In case of trouble, please contact Adam Sampson .''' sys.exit(1) form = UnicodeFormWrapper(cgi.FieldStorage()) self.try_auth(form) # The general form of the path info is "/first[/context]". path_info = os.environ.get("PATH_INFO", "") ps = path_info[1:].split("/", 1) first = ps[0] if len(ps) == 2: context = parse_tags(ps[1].replace("+", " ")) else: context = [] if first == "edit": self.require_auth(form) self.edit_page(form) elif first == "edited": self.require_auth(form) self.edited_page(form) elif first == "delete": self.require_auth(form) self.delete_page(form) elif first == "deleted": self.require_auth(form) self.deleted_page(form) elif first == "login": self.require_auth(form) self.redirect(self.home()) elif first == "logout": self.remove_auth(form) self.redirect(self.url()) elif first == "css": self.out("Content-type: text/css\n\n") self.out(CSS) elif first == "robots.txt": self.robots_txt() else: if first in ("", "all"): username = None else: # This will produce an error if the user # doesn't exist. self.realname(first) username = first format = form.getfirst("format", "html") if format == "html": self.view_page(username, context, form) elif format == "export": self.export(username, context) elif format == "atom": self.feed(username, context) def run(*args): """Start the application.""" Tasty(*args).main()