# hgweb.py - web interface to a mercurial repository # # Copyright 21 May 2005 - (c) 2005 Jake Edge # Copyright 2005 Matt Mackall # # This software may be used and distributed according to the terms # of the GNU General Public License, incorporated herein by reference. import os, cgi, sys from demandload import demandload demandload(globals(), "mdiff time re socket zlib errno ui hg ConfigParser") demandload(globals(), "zipfile tempfile StringIO tarfile BaseHTTPServer") from node import * def templatepath(): for f in "templates", "../templates": p = os.path.join(os.path.dirname(__file__), f) if os.path.isdir(p): return p def age(t): def plural(t, c): if c == 1: return t return t + "s" def fmt(t, c): return "%d %s" % (c, plural(t, c)) now = time.time() delta = max(1, int(now - t)) scales = [["second", 1], ["minute", 60], ["hour", 3600], ["day", 3600 * 24], ["week", 3600 * 24 * 7], ["month", 3600 * 24 * 30], ["year", 3600 * 24 * 365]] scales.reverse() for t, s in scales: n = delta / s if n >= 2 or s == 1: return fmt(t, n) def nl2br(text): return text.replace('\n', '
\n') def obfuscate(text): return ''.join(['&#%d;' % ord(c) for c in text]) def up(p): if p[0] != "/": p = "/" + p if p[-1] == "/": p = p[:-1] up = os.path.dirname(p) if up == "/": return "/" return up + "/" class hgrequest: def __init__(self, inp=None, out=None, env=None): self.inp = inp or sys.stdin self.out = out or sys.stdout self.env = env or os.environ self.form = cgi.parse(self.inp, self.env) def write(self, *things): for thing in things: if hasattr(thing, "__iter__"): for part in thing: self.write(part) else: try: self.out.write(str(thing)) except socket.error, inst: if inst[0] != errno.ECONNRESET: raise def header(self, headers=[('Content-type','text/html')]): for header in headers: self.out.write("%s: %s\r\n" % header) self.out.write("\r\n") def httphdr(self, type, file="", size=0): headers = [('Content-type', type)] if file: headers.append(('Content-disposition', 'attachment; filename=%s' % file)) if size > 0: headers.append(('Content-length', str(size))) self.header(headers) class templater: def __init__(self, mapfile, filters={}, defaults={}): self.cache = {} self.map = {} self.base = os.path.dirname(mapfile) self.filters = filters self.defaults = defaults for l in file(mapfile): m = re.match(r'(\S+)\s*=\s*"(.*)"$', l) if m: self.cache[m.group(1)] = m.group(2) else: m = re.match(r'(\S+)\s*=\s*(\S+)', l) if m: self.map[m.group(1)] = os.path.join(self.base, m.group(2)) else: raise LookupError("unknown map entry '%s'" % l) def __call__(self, t, **map): m = self.defaults.copy() m.update(map) try: tmpl = self.cache[t] except KeyError: tmpl = self.cache[t] = file(self.map[t]).read() return self.template(tmpl, self.filters, **m) def template(self, tmpl, filters={}, **map): while tmpl: m = re.search(r"#([a-zA-Z0-9]+)((%[a-zA-Z0-9]+)*)((\|[a-zA-Z0-9]+)*)#", tmpl) if m: yield tmpl[:m.start(0)] v = map.get(m.group(1), "") v = callable(v) and v(**map) or v format = m.group(2) fl = m.group(4) if format: q = v.__iter__ for i in q(): lm = map.copy() lm.update(i) yield self(format[1:], **lm) v = "" elif fl: for f in fl.split("|")[1:]: v = filters[f](v) yield v tmpl = tmpl[m.end(0):] else: yield tmpl return def rfc822date(x): return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(x)) common_filters = { "escape": cgi.escape, "age": age, "date": (lambda x: time.asctime(time.gmtime(x))), "addbreaks": nl2br, "obfuscate": obfuscate, "short": (lambda x: x[:12]), "firstline": (lambda x: x.splitlines(1)[0]), "permissions": (lambda x: x and "-rwxr-xr-x" or "-rw-r--r--"), "rfc822date": rfc822date, } class hgweb: def __init__(self, repo, name=None): if type(repo) == type(""): self.repo = hg.repository(ui.ui(), repo) else: self.repo = repo self.mtime = -1 self.reponame = name self.archives = 'zip', 'gz', 'bz2' def refresh(self): s = os.stat(os.path.join(self.repo.root, ".hg", "00changelog.i")) if s.st_mtime != self.mtime: self.mtime = s.st_mtime self.repo = hg.repository(self.repo.ui, self.repo.root) self.maxchanges = int(self.repo.ui.config("web", "maxchanges", 10)) self.maxfiles = int(self.repo.ui.config("web", "maxfiles", 10)) self.allowpull = self.repo.ui.configbool("web", "allowpull", True) def date(self, cs): return time.asctime(time.gmtime(float(cs[2].split(' ')[0]))) def listfiles(self, files, mf): for f in files[:self.maxfiles]: yield self.t("filenodelink", node=hex(mf[f]), file=f) if len(files) > self.maxfiles: yield self.t("fileellipses") def listfilediffs(self, files, changeset): for f in files[:self.maxfiles]: yield self.t("filedifflink", node=hex(changeset), file=f) if len(files) > self.maxfiles: yield self.t("fileellipses") def parents(self, t1, nodes=[], rev=None,**args): if not rev: rev = lambda x: "" for node in nodes: if node != nullid: yield self.t(t1, node=hex(node), rev=rev(node), **args) def showtag(self, t1, node=nullid, **args): for t in self.repo.nodetags(node): yield self.t(t1, tag=t, **args) def diff(self, node1, node2, files): def filterfiles(list, files): l = [x for x in list if x in files] for f in files: if f[-1] != os.sep: f += os.sep l += [x for x in list if x.startswith(f)] return l parity = [0] def diffblock(diff, f, fn): yield self.t("diffblock", lines=prettyprintlines(diff), parity=parity[0], file=f, filenode=hex(fn or nullid)) parity[0] = 1 - parity[0] def prettyprintlines(diff): for l in diff.splitlines(1): if l.startswith('+'): yield self.t("difflineplus", line=l) elif l.startswith('-'): yield self.t("difflineminus", line=l) elif l.startswith('@'): yield self.t("difflineat", line=l) else: yield self.t("diffline", line=l) r = self.repo cl = r.changelog mf = r.manifest change1 = cl.read(node1) change2 = cl.read(node2) mmap1 = mf.read(change1[0]) mmap2 = mf.read(change2[0]) date1 = self.date(change1) date2 = self.date(change2) c, a, d, u = r.changes(node1, node2) if files: c, a, d = map(lambda x: filterfiles(x, files), (c, a, d)) for f in c: to = r.file(f).read(mmap1[f]) tn = r.file(f).read(mmap2[f]) yield diffblock(mdiff.unidiff(to, date1, tn, date2, f), f, tn) for f in a: to = None tn = r.file(f).read(mmap2[f]) yield diffblock(mdiff.unidiff(to, date1, tn, date2, f), f, tn) for f in d: to = r.file(f).read(mmap1[f]) tn = None yield diffblock(mdiff.unidiff(to, date1, tn, date2, f), f, tn) def changelog(self, pos): def changenav(**map): def seq(factor=1): yield 1 * factor yield 3 * factor #yield 5 * factor for f in seq(factor * 10): yield f l = [] for f in seq(): if f < self.maxchanges / 2: continue if f > count: break r = "%d" % f if pos + f < count: l.append(("+" + r, pos + f)) if pos - f >= 0: l.insert(0, ("-" + r, pos - f)) yield {"rev": 0, "label": "(0)"} for label, rev in l: yield {"label": label, "rev": rev} yield {"label": "tip", "rev": ""} def changelist(**map): parity = (start - end) & 1 cl = self.repo.changelog l = [] # build a list in forward order for efficiency for i in range(start, end): n = cl.node(i) changes = cl.read(n) hn = hex(n) t = float(changes[2].split(' ')[0]) l.insert(0, {"parity": parity, "author": changes[1], "parent": self.parents("changelogparent", cl.parents(n), cl.rev), "changelogtag": self.showtag("changelogtag",n), "manifest": hex(changes[0]), "desc": changes[4], "date": t, "files": self.listfilediffs(changes[3], n), "rev": i, "node": hn}) parity = 1 - parity for e in l: yield e cl = self.repo.changelog mf = cl.read(cl.tip())[0] count = cl.count() start = max(0, pos - self.maxchanges + 1) end = min(count, start + self.maxchanges) pos = end - 1 yield self.t('changelog', changenav=changenav, manifest=hex(mf), rev=pos, changesets=count, entries=changelist) def search(self, query): def changelist(**map): cl = self.repo.changelog count = 0 qw = query.lower().split() def revgen(): for i in range(cl.count() - 1, 0, -100): l = [] for j in range(max(0, i - 100), i): n = cl.node(j) changes = cl.read(n) l.append((n, j, changes)) l.reverse() for e in l: yield e for n, i, changes in revgen(): miss = 0 for q in qw: if not (q in changes[1].lower() or q in changes[4].lower() or q in " ".join(changes[3][:20]).lower()): miss = 1 break if miss: continue count += 1 hn = hex(n) t = float(changes[2].split(' ')[0]) yield self.t('searchentry', parity=count & 1, author=changes[1], parent=self.parents("changelogparent", cl.parents(n), cl.rev), changelogtag=self.showtag("changelogtag",n), manifest=hex(changes[0]), desc=changes[4], date=t, files=self.listfilediffs(changes[3], n), rev=i, node=hn) if count >= self.maxchanges: break cl = self.repo.changelog mf = cl.read(cl.tip())[0] yield self.t('search', query=query, manifest=hex(mf), entries=changelist) def changeset(self, nodeid): n = bin(nodeid) cl = self.repo.changelog changes = cl.read(n) p1 = cl.parents(n)[0] t = float(changes[2].split(' ')[0]) files = [] mf = self.repo.manifest.read(changes[0]) for f in changes[3]: files.append(self.t("filenodelink", filenode=hex(mf.get(f, nullid)), file=f)) def diff(**map): yield self.diff(p1, n, None) def archivelist(): for i in self.archives: if self.repo.ui.configbool("web", "allow" + i, False): yield {"type" : i, "node" : nodeid} yield self.t('changeset', diff=diff, rev=cl.rev(n), node=nodeid, parent=self.parents("changesetparent", cl.parents(n), cl.rev), changesettag=self.showtag("changesettag",n), manifest=hex(changes[0]), author=changes[1], desc=changes[4], date=t, files=files, archives=archivelist()) def filelog(self, f, filenode): cl = self.repo.changelog fl = self.repo.file(f) count = fl.count() def entries(**map): l = [] parity = (count - 1) & 1 for i in range(count): n = fl.node(i) lr = fl.linkrev(n) cn = cl.node(lr) cs = cl.read(cl.node(lr)) t = float(cs[2].split(' ')[0]) l.insert(0, {"parity": parity, "filenode": hex(n), "filerev": i, "file": f, "node": hex(cn), "author": cs[1], "date": t, "parent": self.parents("filelogparent", fl.parents(n), fl.rev, file=f), "desc": cs[4]}) parity = 1 - parity for e in l: yield e yield self.t("filelog", file=f, filenode=filenode, entries=entries) def filerevision(self, f, node): fl = self.repo.file(f) n = bin(node) text = fl.read(n) changerev = fl.linkrev(n) cl = self.repo.changelog cn = cl.node(changerev) cs = cl.read(cn) t = float(cs[2].split(' ')[0]) mfn = cs[0] def lines(): for l, t in enumerate(text.splitlines(1)): yield {"line": t, "linenumber": "% 6d" % (l + 1), "parity": l & 1} yield self.t("filerevision", file=f, filenode=node, path=up(f), text=lines(), rev=changerev, node=hex(cn), manifest=hex(mfn), author=cs[1], date=t, parent=self.parents("filerevparent", fl.parents(n), fl.rev, file=f), permissions=self.repo.manifest.readflags(mfn)[f]) def fileannotate(self, f, node): bcache = {} ncache = {} fl = self.repo.file(f) n = bin(node) changerev = fl.linkrev(n) cl = self.repo.changelog cn = cl.node(changerev) cs = cl.read(cn) t = float(cs[2].split(' ')[0]) mfn = cs[0] def annotate(**map): parity = 1 last = None for r, l in fl.annotate(n): try: cnode = ncache[r] except KeyError: cnode = ncache[r] = self.repo.changelog.node(r) try: name = bcache[r] except KeyError: cl = self.repo.changelog.read(cnode) bcache[r] = name = self.repo.ui.shortuser(cl[1]) if last != cnode: parity = 1 - parity last = cnode yield {"parity": parity, "node": hex(cnode), "rev": r, "author": name, "file": f, "line": l} yield self.t("fileannotate", file=f, filenode=node, annotate=annotate, path=up(f), rev=changerev, node=hex(cn), manifest=hex(mfn), author=cs[1], date=t, parent=self.parents("fileannotateparent", fl.parents(n), fl.rev, file=f), permissions=self.repo.manifest.readflags(mfn)[f]) def manifest(self, mnode, path): mf = self.repo.manifest.read(bin(mnode)) rev = self.repo.manifest.rev(bin(mnode)) node = self.repo.changelog.node(rev) mff=self.repo.manifest.readflags(bin(mnode)) files = {} p = path[1:] l = len(p) for f,n in mf.items(): if f[:l] != p: continue remain = f[l:] if "/" in remain: short = remain[:remain.find("/") + 1] # bleah files[short] = (f, None) else: short = os.path.basename(remain) files[short] = (f, n) def filelist(**map): parity = 0 fl = files.keys() fl.sort() for f in fl: full, fnode = files[f] if not fnode: continue yield {"file": full, "manifest": mnode, "filenode": hex(fnode), "parity": parity, "basename": f, "permissions": mff[full]} parity = 1 - parity def dirlist(**map): parity = 0 fl = files.keys() fl.sort() for f in fl: full, fnode = files[f] if fnode: continue yield {"parity": parity, "path": os.path.join(path, f), "manifest": mnode, "basename": f[:-1]} parity = 1 - parity yield self.t("manifest", manifest=mnode, rev=rev, node=hex(node), path=path, up=up(path), fentries=filelist, dentries=dirlist) def tags(self): cl = self.repo.changelog mf = cl.read(cl.tip())[0] i = self.repo.tagslist() i.reverse() def entries(**map): parity = 0 for k,n in i: yield {"parity": parity, "tag": k, "node": hex(n)} parity = 1 - parity yield self.t("tags", manifest=hex(mf), entries=entries) def filediff(self, file, changeset): n = bin(changeset) cl = self.repo.changelog p1 = cl.parents(n)[0] cs = cl.read(n) mf = self.repo.manifest.read(cs[0]) def diff(**map): yield self.diff(p1, n, file) yield self.t("filediff", file=file, filenode=hex(mf.get(file, nullid)), node=changeset, rev=self.repo.changelog.rev(n), parent=self.parents("filediffparent", cl.parents(n), cl.rev), diff=diff) def archive(self, req, cnode, type): cs = self.repo.changelog.read(cnode) mnode = cs[0] mf = self.repo.manifest.read(mnode) rev = self.repo.manifest.rev(mnode) reponame = re.sub(r"\W+", "-", self.reponame) name = "%s-%s/" % (reponame, short(cnode)) files = mf.keys() files.sort() if type == 'zip': tmp = tempfile.mkstemp()[1] try: zf = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) for f in files: zf.writestr(name + f, self.repo.file(f).read(mf[f])) zf.close() f = open(tmp, 'r') req.httphdr('application/zip', name[:-1] + '.zip', os.path.getsize(tmp)) req.write(f.read()) f.close() finally: os.unlink(tmp) else: tf = tarfile.TarFile.open(mode='w|' + type, fileobj=req.out) mff = self.repo.manifest.readflags(mnode) mtime = int(time.time()) if type == "gz": encoding = "gzip" else: encoding = "x-bzip2" req.header([('Content-type', 'application/x-tar'), ('Content-disposition', 'attachment; filename=%s%s%s' % (name[:-1], '.tar.', type)), ('Content-encoding', encoding)]) for fname in files: rcont = self.repo.file(fname).read(mf[fname]) finfo = tarfile.TarInfo(name + fname) finfo.mtime = mtime finfo.size = len(rcont) finfo.mode = mff[fname] and 0755 or 0644 tf.addfile(finfo, StringIO.StringIO(rcont)) tf.close() # add tags to things # tags -> list of changesets corresponding to tags # find tag, changeset, file def run(self, req=hgrequest()): def header(**map): yield self.t("header", **map) def footer(**map): yield self.t("footer", **map) self.refresh() t = self.repo.ui.config("web", "templates", templatepath()) m = os.path.join(t, "map") style = self.repo.ui.config("web", "style", "") if req.form.has_key('style'): style = req.form['style'][0] if style: b = os.path.basename("map-" + style) p = os.path.join(t, b) if os.path.isfile(p): m = p port = req.env["SERVER_PORT"] port = port != "80" and (":" + port) or "" uri = req.env["REQUEST_URI"] if "?" in uri: uri = uri.split("?")[0] url = "http://%s%s%s" % (req.env["SERVER_NAME"], port, uri) if not self.reponame: self.reponame = (self.repo.ui.config("web", "name") or uri.strip('/') or self.repo.root) self.t = templater(m, common_filters, {"url": url, "repo": self.reponame, "header": header, "footer": footer, }) if not req.form.has_key('cmd'): req.form['cmd'] = [self.t.cache['default'],] if req.form['cmd'][0] == 'changelog': c = self.repo.changelog.count() - 1 hi = c if req.form.has_key('rev'): hi = req.form['rev'][0] try: hi = self.repo.changelog.rev(self.repo.lookup(hi)) except hg.RepoError: req.write(self.search(hi)) return req.write(self.changelog(hi)) elif req.form['cmd'][0] == 'changeset': req.write(self.changeset(req.form['node'][0])) elif req.form['cmd'][0] == 'manifest': req.write(self.manifest(req.form['manifest'][0], req.form['path'][0])) elif req.form['cmd'][0] == 'tags': req.write(self.tags()) elif req.form['cmd'][0] == 'filediff': req.write(self.filediff(req.form['file'][0], req.form['node'][0])) elif req.form['cmd'][0] == 'file': req.write(self.filerevision(req.form['file'][0], req.form['filenode'][0])) elif req.form['cmd'][0] == 'annotate': req.write(self.fileannotate(req.form['file'][0], req.form['filenode'][0])) elif req.form['cmd'][0] == 'filelog': req.write(self.filelog(req.form['file'][0], req.form['filenode'][0])) elif req.form['cmd'][0] == 'heads': req.httphdr("application/mercurial-0.1") h = self.repo.heads() req.write(" ".join(map(hex, h)) + "\n") elif req.form['cmd'][0] == 'branches': req.httphdr("application/mercurial-0.1") nodes = [] if req.form.has_key('nodes'): nodes = map(bin, req.form['nodes'][0].split(" ")) for b in self.repo.branches(nodes): req.write(" ".join(map(hex, b)) + "\n") elif req.form['cmd'][0] == 'between': req.httphdr("application/mercurial-0.1") nodes = [] if req.form.has_key('pairs'): pairs = [map(bin, p.split("-")) for p in req.form['pairs'][0].split(" ")] for b in self.repo.between(pairs): req.write(" ".join(map(hex, b)) + "\n") elif req.form['cmd'][0] == 'changegroup': req.httphdr("application/mercurial-0.1") nodes = [] if not self.allowpull: return if req.form.has_key('roots'): nodes = map(bin, req.form['roots'][0].split(" ")) z = zlib.compressobj() f = self.repo.changegroup(nodes) while 1: chunk = f.read(4096) if not chunk: break req.write(z.compress(chunk)) req.write(z.flush()) elif req.form['cmd'][0] == 'archive': changeset = bin(req.form['node'][0]) type = req.form['type'][0] if (type in self.archives and self.repo.ui.configbool("web", "allow" + type, False)): self.archive(req, changeset, type) return req.write(self.t("error")) else: req.write(self.t("error")) def create_server(repo): def openlog(opt, default): if opt and opt != '-': return open(opt, 'w') return default address = repo.ui.config("web", "address", "") port = int(repo.ui.config("web", "port", 8000)) use_ipv6 = repo.ui.configbool("web", "ipv6") accesslog = openlog(repo.ui.config("web", "accesslog", "-"), sys.stdout) errorlog = openlog(repo.ui.config("web", "errorlog", "-"), sys.stderr) class IPv6HTTPServer(BaseHTTPServer.HTTPServer): address_family = getattr(socket, 'AF_INET6', None) def __init__(self, *args, **kwargs): if self.address_family is None: raise hg.RepoError('IPv6 not available on this system') BaseHTTPServer.HTTPServer.__init__(self, *args, **kwargs) class hgwebhandler(BaseHTTPServer.BaseHTTPRequestHandler): def log_error(self, format, *args): errorlog.write("%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), format % args)) def log_message(self, format, *args): accesslog.write("%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), format % args)) def do_POST(self): try: self.do_hgweb() except socket.error, inst: if inst[0] != errno.EPIPE: raise def do_GET(self): self.do_POST() def do_hgweb(self): query = "" p = self.path.find("?") if p: query = self.path[p + 1:] query = query.replace('+', ' ') env = {} env['GATEWAY_INTERFACE'] = 'CGI/1.1' env['REQUEST_METHOD'] = self.command env['SERVER_NAME'] = self.server.server_name env['SERVER_PORT'] = str(self.server.server_port) env['REQUEST_URI'] = "/" if query: env['QUERY_STRING'] = query host = self.address_string() if host != self.client_address[0]: env['REMOTE_HOST'] = host env['REMOTE_ADDR'] = self.client_address[0] if self.headers.typeheader is None: env['CONTENT_TYPE'] = self.headers.type else: env['CONTENT_TYPE'] = self.headers.typeheader length = self.headers.getheader('content-length') if length: env['CONTENT_LENGTH'] = length accept = [] for line in self.headers.getallmatchingheaders('accept'): if line[:1] in "\t\n\r ": accept.append(line.strip()) else: accept = accept + line[7:].split(',') env['HTTP_ACCEPT'] = ','.join(accept) req = hgrequest(self.rfile, self.wfile, env) self.send_response(200, "Script output follows") hg.run(req) hg = hgweb(repo) if use_ipv6: return IPv6HTTPServer((address, port), hgwebhandler) else: return BaseHTTPServer.HTTPServer((address, port), hgwebhandler) def server(path, name, templates, address, port, use_ipv6=False, accesslog=sys.stdout, errorlog=sys.stderr): httpd = create_server(path, name, templates, address, port, use_ipv6, accesslog, errorlog) httpd.serve_forever() # This is a stopgap class hgwebdir: def __init__(self, config): def cleannames(items): return [(name.strip('/'), path) for name, path in items] if type(config) == type([]): self.repos = cleannames(config) elif type(config) == type({}): self.repos = cleannames(config.items()) self.repos.sort() else: cp = ConfigParser.SafeConfigParser() cp.read(config) self.repos = cleannames(cp.items("paths")) self.repos.sort() def run(self, req=hgrequest()): def header(**map): yield tmpl("header", **map) def footer(**map): yield tmpl("footer", **map) m = os.path.join(templatepath(), "map") tmpl = templater(m, common_filters, {"header": header, "footer": footer}) def entries(**map): parity = 0 for name, path in self.repos: u = ui.ui() try: u.readconfig(file(os.path.join(path, '.hg', 'hgrc'))) except IOError: pass get = u.config url = ('/'.join([req.env["REQUEST_URI"].split('?')[0], name]) .replace("//", "/")) yield dict(contact=(get("ui", "username") or # preferred get("web", "contact") or # deprecated get("web", "author", "unknown")), # also name=get("web", "name", name), url=url, parity=parity, shortdesc=get("web", "description", "unknown"), lastupdate=os.stat(os.path.join(path, ".hg", "00changelog.d")).st_mtime) parity = 1 - parity virtual = req.env.get("PATH_INFO", "").strip('/') if virtual: real = dict(self.repos).get(virtual) if real: hgweb(real).run(req) else: req.write(tmpl("notfound", repo=virtual)) else: req.write(tmpl("index", entries=entries))