121
|
1 # hgweb/hgwebdir_mod.py - Web interface for a directory of repositories.
|
|
2 #
|
|
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
|
|
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
|
|
5 #
|
|
6 # This software may be used and distributed according to the terms of the
|
|
7 # GNU General Public License version 2, incorporated herein by reference.
|
|
8
|
|
9 import os, re, time
|
|
10 from mercurial.i18n import _
|
|
11 from mercurial import ui, hg, util, templater
|
|
12 from mercurial import error, encoding
|
|
13 from common import ErrorResponse, get_mtime, staticfile, paritygen,\
|
|
14 get_contact, HTTP_OK, HTTP_NOT_FOUND, HTTP_SERVER_ERROR
|
|
15 from hgweb_mod import hgweb
|
|
16 from request import wsgirequest
|
|
17 import webutil
|
|
18
|
|
19 def cleannames(items):
|
|
20 return [(util.pconvert(name).strip('/'), path) for name, path in items]
|
|
21
|
|
22 def findrepos(paths):
|
|
23 repos = {}
|
|
24 for prefix, root in cleannames(paths):
|
|
25 roothead, roottail = os.path.split(root)
|
|
26 # "foo = /bar/*" makes every subrepo of /bar/ to be
|
|
27 # mounted as foo/subrepo
|
|
28 # and "foo = /bar/**" also recurses into the subdirectories,
|
|
29 # remember to use it without working dir.
|
|
30 try:
|
|
31 recurse = {'*': False, '**': True}[roottail]
|
|
32 except KeyError:
|
|
33 repos[prefix] = root
|
|
34 continue
|
|
35 roothead = os.path.normpath(roothead)
|
|
36 for path in util.walkrepos(roothead, followsym=True, recurse=recurse):
|
|
37 path = os.path.normpath(path)
|
|
38 name = util.pconvert(path[len(roothead):]).strip('/')
|
|
39 if prefix:
|
|
40 name = prefix + '/' + name
|
|
41 repos[name] = path
|
|
42 return repos.items()
|
|
43
|
|
44 class hgwebdir(object):
|
|
45 refreshinterval = 20
|
|
46
|
|
47 def __init__(self, conf, baseui=None):
|
|
48 self.conf = conf
|
|
49 self.baseui = baseui
|
|
50 self.lastrefresh = 0
|
|
51 self.refresh()
|
|
52
|
|
53 def refresh(self):
|
|
54 if self.lastrefresh + self.refreshinterval > time.time():
|
|
55 return
|
|
56
|
|
57 if self.baseui:
|
|
58 self.ui = self.baseui.copy()
|
|
59 else:
|
|
60 self.ui = ui.ui()
|
|
61 self.ui.setconfig('ui', 'report_untrusted', 'off')
|
|
62 self.ui.setconfig('ui', 'interactive', 'off')
|
|
63
|
|
64 if not isinstance(self.conf, (dict, list, tuple)):
|
|
65 map = {'paths': 'hgweb-paths'}
|
|
66 self.ui.readconfig(self.conf, remap=map, trust=True)
|
|
67 paths = self.ui.configitems('hgweb-paths')
|
|
68 elif isinstance(self.conf, (list, tuple)):
|
|
69 paths = self.conf
|
|
70 elif isinstance(self.conf, dict):
|
|
71 paths = self.conf.items()
|
|
72
|
|
73 encoding.encoding = self.ui.config('web', 'encoding',
|
|
74 encoding.encoding)
|
|
75 self.motd = self.ui.config('web', 'motd')
|
|
76 self.style = self.ui.config('web', 'style', 'paper')
|
|
77 self.stripecount = self.ui.config('web', 'stripes', 1)
|
|
78 if self.stripecount:
|
|
79 self.stripecount = int(self.stripecount)
|
|
80 self._baseurl = self.ui.config('web', 'baseurl')
|
|
81
|
|
82 self.repos = findrepos(paths)
|
|
83 for prefix, root in self.ui.configitems('collections'):
|
|
84 prefix = util.pconvert(prefix)
|
|
85 for path in util.walkrepos(root, followsym=True):
|
|
86 repo = os.path.normpath(path)
|
|
87 name = util.pconvert(repo)
|
|
88 if name.startswith(prefix):
|
|
89 name = name[len(prefix):]
|
|
90 self.repos.append((name.lstrip('/'), repo))
|
|
91
|
|
92 self.repos.sort()
|
|
93 self.lastrefresh = time.time()
|
|
94
|
|
95 def run(self):
|
|
96 if not os.environ.get('GATEWAY_INTERFACE', '').startswith("CGI/1."):
|
|
97 raise RuntimeError("This function is only intended to be "
|
|
98 "called while running as a CGI script.")
|
|
99 import mercurial.hgweb.wsgicgi as wsgicgi
|
|
100 wsgicgi.launch(self)
|
|
101
|
|
102 def __call__(self, env, respond):
|
|
103 req = wsgirequest(env, respond)
|
|
104 return self.run_wsgi(req)
|
|
105
|
|
106 def read_allowed(self, ui, req):
|
|
107 """Check allow_read and deny_read config options of a repo's ui object
|
|
108 to determine user permissions. By default, with neither option set (or
|
|
109 both empty), allow all users to read the repo. There are two ways a
|
|
110 user can be denied read access: (1) deny_read is not empty, and the
|
|
111 user is unauthenticated or deny_read contains user (or *), and (2)
|
|
112 allow_read is not empty and the user is not in allow_read. Return True
|
|
113 if user is allowed to read the repo, else return False."""
|
|
114
|
|
115 user = req.env.get('REMOTE_USER')
|
|
116
|
|
117 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
|
|
118 if deny_read and (not user or deny_read == ['*'] or user in deny_read):
|
|
119 return False
|
|
120
|
|
121 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
|
|
122 # by default, allow reading if no allow_read option has been set
|
|
123 if (not allow_read) or (allow_read == ['*']) or (user in allow_read):
|
|
124 return True
|
|
125
|
|
126 return False
|
|
127
|
|
128 def run_wsgi(self, req):
|
|
129 try:
|
|
130 try:
|
|
131 self.refresh()
|
|
132
|
|
133 virtual = req.env.get("PATH_INFO", "").strip('/')
|
|
134 tmpl = self.templater(req)
|
|
135 ctype = tmpl('mimetype', encoding=encoding.encoding)
|
|
136 ctype = templater.stringify(ctype)
|
|
137
|
|
138 # a static file
|
|
139 if virtual.startswith('static/') or 'static' in req.form:
|
|
140 if virtual.startswith('static/'):
|
|
141 fname = virtual[7:]
|
|
142 else:
|
|
143 fname = req.form['static'][0]
|
|
144 static = templater.templatepath('static')
|
|
145 return (staticfile(static, fname, req),)
|
|
146
|
|
147 # top-level index
|
|
148 elif not virtual:
|
|
149 req.respond(HTTP_OK, ctype)
|
|
150 return self.makeindex(req, tmpl)
|
|
151
|
|
152 # nested indexes and hgwebs
|
|
153
|
|
154 repos = dict(self.repos)
|
|
155 while virtual:
|
|
156 real = repos.get(virtual)
|
|
157 if real:
|
|
158 req.env['REPO_NAME'] = virtual
|
|
159 try:
|
|
160 repo = hg.repository(self.ui, real)
|
|
161 return hgweb(repo).run_wsgi(req)
|
|
162 except IOError, inst:
|
|
163 msg = inst.strerror
|
|
164 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
|
|
165 except error.RepoError, inst:
|
|
166 raise ErrorResponse(HTTP_SERVER_ERROR, str(inst))
|
|
167
|
|
168 # browse subdirectories
|
|
169 subdir = virtual + '/'
|
|
170 if [r for r in repos if r.startswith(subdir)]:
|
|
171 req.respond(HTTP_OK, ctype)
|
|
172 return self.makeindex(req, tmpl, subdir)
|
|
173
|
|
174 up = virtual.rfind('/')
|
|
175 if up < 0:
|
|
176 break
|
|
177 virtual = virtual[:up]
|
|
178
|
|
179 # prefixes not found
|
|
180 req.respond(HTTP_NOT_FOUND, ctype)
|
|
181 return tmpl("notfound", repo=virtual)
|
|
182
|
|
183 except ErrorResponse, err:
|
|
184 req.respond(err, ctype)
|
|
185 return tmpl('error', error=err.message or '')
|
|
186 finally:
|
|
187 tmpl = None
|
|
188
|
|
189 def makeindex(self, req, tmpl, subdir=""):
|
|
190
|
|
191 def archivelist(ui, nodeid, url):
|
|
192 allowed = ui.configlist("web", "allow_archive", untrusted=True)
|
|
193 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
|
|
194 if i[0] in allowed or ui.configbool("web", "allow" + i[0],
|
|
195 untrusted=True):
|
|
196 yield {"type" : i[0], "extension": i[1],
|
|
197 "node": nodeid, "url": url}
|
|
198
|
|
199 sortdefault = 'name', False
|
|
200 def entries(sortcolumn="", descending=False, subdir="", **map):
|
|
201 rows = []
|
|
202 parity = paritygen(self.stripecount)
|
|
203 for name, path in self.repos:
|
|
204 if not name.startswith(subdir):
|
|
205 continue
|
|
206 name = name[len(subdir):]
|
|
207
|
|
208 u = self.ui.copy()
|
|
209 try:
|
|
210 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
|
|
211 except Exception, e:
|
|
212 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
|
|
213 continue
|
|
214 def get(section, name, default=None):
|
|
215 return u.config(section, name, default, untrusted=True)
|
|
216
|
|
217 if u.configbool("web", "hidden", untrusted=True):
|
|
218 continue
|
|
219
|
|
220 if not self.read_allowed(u, req):
|
|
221 continue
|
|
222
|
|
223 parts = [name]
|
|
224 if 'PATH_INFO' in req.env:
|
|
225 parts.insert(0, req.env['PATH_INFO'].rstrip('/'))
|
|
226 if req.env['SCRIPT_NAME']:
|
|
227 parts.insert(0, req.env['SCRIPT_NAME'])
|
|
228 m = re.match('((?:https?://)?)(.*)', '/'.join(parts))
|
|
229 # squish repeated slashes out of the path component
|
|
230 url = m.group(1) + re.sub('/+', '/', m.group(2)) + '/'
|
|
231
|
|
232 # update time with local timezone
|
|
233 try:
|
|
234 d = (get_mtime(path), util.makedate()[1])
|
|
235 except OSError:
|
|
236 continue
|
|
237
|
|
238 contact = get_contact(get)
|
|
239 description = get("web", "description", "")
|
|
240 name = get("web", "name", name)
|
|
241 row = dict(contact=contact or "unknown",
|
|
242 contact_sort=contact.upper() or "unknown",
|
|
243 name=name,
|
|
244 name_sort=name,
|
|
245 url=url,
|
|
246 description=description or "unknown",
|
|
247 description_sort=description.upper() or "unknown",
|
|
248 lastchange=d,
|
|
249 lastchange_sort=d[1]-d[0],
|
|
250 archives=archivelist(u, "tip", url))
|
|
251 if (not sortcolumn or (sortcolumn, descending) == sortdefault):
|
|
252 # fast path for unsorted output
|
|
253 row['parity'] = parity.next()
|
|
254 yield row
|
|
255 else:
|
|
256 rows.append((row["%s_sort" % sortcolumn], row))
|
|
257 if rows:
|
|
258 rows.sort()
|
|
259 if descending:
|
|
260 rows.reverse()
|
|
261 for key, row in rows:
|
|
262 row['parity'] = parity.next()
|
|
263 yield row
|
|
264
|
|
265 self.refresh()
|
|
266 sortable = ["name", "description", "contact", "lastchange"]
|
|
267 sortcolumn, descending = sortdefault
|
|
268 if 'sort' in req.form:
|
|
269 sortcolumn = req.form['sort'][0]
|
|
270 descending = sortcolumn.startswith('-')
|
|
271 if descending:
|
|
272 sortcolumn = sortcolumn[1:]
|
|
273 if sortcolumn not in sortable:
|
|
274 sortcolumn = ""
|
|
275
|
|
276 sort = [("sort_%s" % column,
|
|
277 "%s%s" % ((not descending and column == sortcolumn)
|
|
278 and "-" or "", column))
|
|
279 for column in sortable]
|
|
280
|
|
281 self.refresh()
|
|
282 if self._baseurl is not None:
|
|
283 req.env['SCRIPT_NAME'] = self._baseurl
|
|
284
|
|
285 return tmpl("index", entries=entries, subdir=subdir,
|
|
286 sortcolumn=sortcolumn, descending=descending,
|
|
287 **dict(sort))
|
|
288
|
|
289 def templater(self, req):
|
|
290
|
|
291 def header(**map):
|
|
292 yield tmpl('header', encoding=encoding.encoding, **map)
|
|
293
|
|
294 def footer(**map):
|
|
295 yield tmpl("footer", **map)
|
|
296
|
|
297 def motd(**map):
|
|
298 if self.motd is not None:
|
|
299 yield self.motd
|
|
300 else:
|
|
301 yield config('web', 'motd', '')
|
|
302
|
|
303 def config(section, name, default=None, untrusted=True):
|
|
304 return self.ui.config(section, name, default, untrusted)
|
|
305
|
|
306 if self._baseurl is not None:
|
|
307 req.env['SCRIPT_NAME'] = self._baseurl
|
|
308
|
|
309 url = req.env.get('SCRIPT_NAME', '')
|
|
310 if not url.endswith('/'):
|
|
311 url += '/'
|
|
312
|
|
313 vars = {}
|
|
314 style = self.style
|
|
315 if 'style' in req.form:
|
|
316 vars['style'] = style = req.form['style'][0]
|
|
317 start = url[-1] == '?' and '&' or '?'
|
|
318 sessionvars = webutil.sessionvars(vars, start)
|
|
319
|
|
320 staticurl = config('web', 'staticurl') or url + 'static/'
|
|
321 if not staticurl.endswith('/'):
|
|
322 staticurl += '/'
|
|
323
|
|
324 style = 'style' in req.form and req.form['style'][0] or self.style
|
|
325 mapfile = templater.stylemap(style)
|
|
326 tmpl = templater.templater(mapfile,
|
|
327 defaults={"header": header,
|
|
328 "footer": footer,
|
|
329 "motd": motd,
|
|
330 "url": url,
|
|
331 "staticurl": staticurl,
|
|
332 "sessionvars": sessionvars})
|
|
333 return tmpl
|