##// END OF EJS Templates
hgweb: port static file handling to new response API...
Gregory Szorc -
r36889:98baf8de default
parent child Browse files
Show More
@@ -1,251 +1,253 b''
1 1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import absolute_import
10 10
11 11 import base64
12 12 import errno
13 13 import mimetypes
14 14 import os
15 15 import stat
16 16
17 17 from .. import (
18 18 encoding,
19 19 pycompat,
20 20 util,
21 21 )
22 22
23 23 httpserver = util.httpserver
24 24
25 25 HTTP_OK = 200
26 26 HTTP_NOT_MODIFIED = 304
27 27 HTTP_BAD_REQUEST = 400
28 28 HTTP_UNAUTHORIZED = 401
29 29 HTTP_FORBIDDEN = 403
30 30 HTTP_NOT_FOUND = 404
31 31 HTTP_METHOD_NOT_ALLOWED = 405
32 32 HTTP_SERVER_ERROR = 500
33 33
34 34
35 35 def ismember(ui, username, userlist):
36 36 """Check if username is a member of userlist.
37 37
38 38 If userlist has a single '*' member, all users are considered members.
39 39 Can be overridden by extensions to provide more complex authorization
40 40 schemes.
41 41 """
42 42 return userlist == ['*'] or username in userlist
43 43
44 44 def checkauthz(hgweb, req, op):
45 45 '''Check permission for operation based on request data (including
46 46 authentication info). Return if op allowed, else raise an ErrorResponse
47 47 exception.'''
48 48
49 49 user = req.env.get(r'REMOTE_USER')
50 50
51 51 deny_read = hgweb.configlist('web', 'deny_read')
52 52 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
53 53 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
54 54
55 55 allow_read = hgweb.configlist('web', 'allow_read')
56 56 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
57 57 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
58 58
59 59 if op == 'pull' and not hgweb.allowpull:
60 60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
61 61 elif op == 'pull' or op is None: # op is None for interface requests
62 62 return
63 63
64 64 # enforce that you can only push using POST requests
65 65 if req.env[r'REQUEST_METHOD'] != r'POST':
66 66 msg = 'push requires POST request'
67 67 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
68 68
69 69 # require ssl by default for pushing, auth info cannot be sniffed
70 70 # and replayed
71 71 scheme = req.env.get('wsgi.url_scheme')
72 72 if hgweb.configbool('web', 'push_ssl') and scheme != 'https':
73 73 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
74 74
75 75 deny = hgweb.configlist('web', 'deny_push')
76 76 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
77 77 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
78 78
79 79 allow = hgweb.configlist('web', 'allow-push')
80 80 if not (allow and ismember(hgweb.repo.ui, user, allow)):
81 81 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
82 82
83 83 # Hooks for hgweb permission checks; extensions can add hooks here.
84 84 # Each hook is invoked like this: hook(hgweb, request, operation),
85 85 # where operation is either read, pull or push. Hooks should either
86 86 # raise an ErrorResponse exception, or just return.
87 87 #
88 88 # It is possible to do both authentication and authorization through
89 89 # this.
90 90 permhooks = [checkauthz]
91 91
92 92
93 93 class ErrorResponse(Exception):
94 94 def __init__(self, code, message=None, headers=None):
95 95 if message is None:
96 96 message = _statusmessage(code)
97 97 Exception.__init__(self, pycompat.sysstr(message))
98 98 self.code = code
99 99 if headers is None:
100 100 headers = []
101 101 self.headers = headers
102 102
103 103 class continuereader(object):
104 104 """File object wrapper to handle HTTP 100-continue.
105 105
106 106 This is used by servers so they automatically handle Expect: 100-continue
107 107 request headers. On first read of the request body, the 100 Continue
108 108 response is sent. This should trigger the client into actually sending
109 109 the request body.
110 110 """
111 111 def __init__(self, f, write):
112 112 self.f = f
113 113 self._write = write
114 114 self.continued = False
115 115
116 116 def read(self, amt=-1):
117 117 if not self.continued:
118 118 self.continued = True
119 119 self._write('HTTP/1.1 100 Continue\r\n\r\n')
120 120 return self.f.read(amt)
121 121
122 122 def __getattr__(self, attr):
123 123 if attr in ('close', 'readline', 'readlines', '__iter__'):
124 124 return getattr(self.f, attr)
125 125 raise AttributeError
126 126
127 127 def _statusmessage(code):
128 128 responses = httpserver.basehttprequesthandler.responses
129 129 return responses.get(code, ('Error', 'Unknown error'))[0]
130 130
131 131 def statusmessage(code, message=None):
132 132 return '%d %s' % (code, message or _statusmessage(code))
133 133
134 134 def get_stat(spath, fn):
135 135 """stat fn if it exists, spath otherwise"""
136 136 cl_path = os.path.join(spath, fn)
137 137 if os.path.exists(cl_path):
138 138 return os.stat(cl_path)
139 139 else:
140 140 return os.stat(spath)
141 141
142 142 def get_mtime(spath):
143 143 return get_stat(spath, "00changelog.i")[stat.ST_MTIME]
144 144
145 145 def ispathsafe(path):
146 146 """Determine if a path is safe to use for filesystem access."""
147 147 parts = path.split('/')
148 148 for part in parts:
149 149 if (part in ('', pycompat.oscurdir, pycompat.ospardir) or
150 150 pycompat.ossep in part or
151 151 pycompat.osaltsep is not None and pycompat.osaltsep in part):
152 152 return False
153 153
154 154 return True
155 155
156 def staticfile(directory, fname, req):
156 def staticfile(directory, fname, res):
157 157 """return a file inside directory with guessed Content-Type header
158 158
159 159 fname always uses '/' as directory separator and isn't allowed to
160 160 contain unusual path components.
161 161 Content-Type is guessed using the mimetypes module.
162 162 Return an empty string if fname is illegal or file not found.
163 163
164 164 """
165 165 if not ispathsafe(fname):
166 166 return
167 167
168 168 fpath = os.path.join(*fname.split('/'))
169 169 if isinstance(directory, str):
170 170 directory = [directory]
171 171 for d in directory:
172 172 path = os.path.join(d, fpath)
173 173 if os.path.exists(path):
174 174 break
175 175 try:
176 176 os.stat(path)
177 177 ct = mimetypes.guess_type(pycompat.fsdecode(path))[0] or "text/plain"
178 178 with open(path, 'rb') as fh:
179 179 data = fh.read()
180 180
181 req.respond(HTTP_OK, ct, body=data)
181 res.headers['Content-Type'] = ct
182 res.setbodybytes(data)
183 return res
182 184 except TypeError:
183 185 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
184 186 except OSError as err:
185 187 if err.errno == errno.ENOENT:
186 188 raise ErrorResponse(HTTP_NOT_FOUND)
187 189 else:
188 190 raise ErrorResponse(HTTP_SERVER_ERROR,
189 191 encoding.strtolocal(err.strerror))
190 192
191 193 def paritygen(stripecount, offset=0):
192 194 """count parity of horizontal stripes for easier reading"""
193 195 if stripecount and offset:
194 196 # account for offset, e.g. due to building the list in reverse
195 197 count = (stripecount + offset) % stripecount
196 198 parity = (stripecount + offset) // stripecount & 1
197 199 else:
198 200 count = 0
199 201 parity = 0
200 202 while True:
201 203 yield parity
202 204 count += 1
203 205 if stripecount and count >= stripecount:
204 206 parity = 1 - parity
205 207 count = 0
206 208
207 209 def get_contact(config):
208 210 """Return repo contact information or empty string.
209 211
210 212 web.contact is the primary source, but if that is not set, try
211 213 ui.username or $EMAIL as a fallback to display something useful.
212 214 """
213 215 return (config("web", "contact") or
214 216 config("ui", "username") or
215 217 encoding.environ.get("EMAIL") or "")
216 218
217 219 def cspvalues(ui):
218 220 """Obtain the Content-Security-Policy header and nonce value.
219 221
220 222 Returns a 2-tuple of the CSP header value and the nonce value.
221 223
222 224 First value is ``None`` if CSP isn't enabled. Second value is ``None``
223 225 if CSP isn't enabled or if the CSP header doesn't need a nonce.
224 226 """
225 227 # Without demandimport, "import uuid" could have an immediate side-effect
226 228 # running "ldconfig" on Linux trying to find libuuid.
227 229 # With Python <= 2.7.12, that "ldconfig" is run via a shell and the shell
228 230 # may pollute the terminal with:
229 231 #
230 232 # shell-init: error retrieving current directory: getcwd: cannot access
231 233 # parent directories: No such file or directory
232 234 #
233 235 # Python >= 2.7.13 has fixed it by running "ldconfig" directly without a
234 236 # shell (hg changeset a09ae70f3489).
235 237 #
236 238 # Moved "import uuid" from here so it's executed after we know we have
237 239 # a sane cwd (i.e. after dispatch.py cwd check).
238 240 #
239 241 # We can move it back once we no longer need Python <= 2.7.12 support.
240 242 import uuid
241 243
242 244 # Don't allow untrusted CSP setting since it be disable protections
243 245 # from a trusted/global source.
244 246 csp = ui.config('web', 'csp', untrusted=False)
245 247 nonce = None
246 248
247 249 if csp and '%nonce%' in csp:
248 250 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
249 251 csp = csp.replace('%nonce%', nonce)
250 252
251 253 return csp, nonce
@@ -1,546 +1,553 b''
1 1 # hgweb/hgwebdir_mod.py - Web interface for a directory of repositories.
2 2 #
3 3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 from __future__ import absolute_import
10 10
11 11 import os
12 12 import re
13 13 import time
14 14
15 15 from ..i18n import _
16 16
17 17 from .common import (
18 18 ErrorResponse,
19 19 HTTP_NOT_FOUND,
20 20 HTTP_OK,
21 21 HTTP_SERVER_ERROR,
22 22 cspvalues,
23 23 get_contact,
24 24 get_mtime,
25 25 ismember,
26 26 paritygen,
27 27 staticfile,
28 28 )
29 29
30 30 from .. import (
31 31 configitems,
32 32 encoding,
33 33 error,
34 34 hg,
35 35 profiling,
36 36 pycompat,
37 37 scmutil,
38 38 templater,
39 39 ui as uimod,
40 40 util,
41 41 )
42 42
43 43 from . import (
44 44 hgweb_mod,
45 45 request as requestmod,
46 46 webutil,
47 47 wsgicgi,
48 48 )
49 49 from ..utils import dateutil
50 50
51 51 def cleannames(items):
52 52 return [(util.pconvert(name).strip('/'), path) for name, path in items]
53 53
54 54 def findrepos(paths):
55 55 repos = []
56 56 for prefix, root in cleannames(paths):
57 57 roothead, roottail = os.path.split(root)
58 58 # "foo = /bar/*" or "foo = /bar/**" lets every repo /bar/N in or below
59 59 # /bar/ be served as as foo/N .
60 60 # '*' will not search inside dirs with .hg (except .hg/patches),
61 61 # '**' will search inside dirs with .hg (and thus also find subrepos).
62 62 try:
63 63 recurse = {'*': False, '**': True}[roottail]
64 64 except KeyError:
65 65 repos.append((prefix, root))
66 66 continue
67 67 roothead = os.path.normpath(os.path.abspath(roothead))
68 68 paths = scmutil.walkrepos(roothead, followsym=True, recurse=recurse)
69 69 repos.extend(urlrepos(prefix, roothead, paths))
70 70 return repos
71 71
72 72 def urlrepos(prefix, roothead, paths):
73 73 """yield url paths and filesystem paths from a list of repo paths
74 74
75 75 >>> conv = lambda seq: [(v, util.pconvert(p)) for v,p in seq]
76 76 >>> conv(urlrepos(b'hg', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
77 77 [('hg/r', '/opt/r'), ('hg/r/r', '/opt/r/r'), ('hg', '/opt')]
78 78 >>> conv(urlrepos(b'', b'/opt', [b'/opt/r', b'/opt/r/r', b'/opt']))
79 79 [('r', '/opt/r'), ('r/r', '/opt/r/r'), ('', '/opt')]
80 80 """
81 81 for path in paths:
82 82 path = os.path.normpath(path)
83 83 yield (prefix + '/' +
84 84 util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
85 85
86 86 def geturlcgivars(baseurl, port):
87 87 """
88 88 Extract CGI variables from baseurl
89 89
90 90 >>> geturlcgivars(b"http://host.org/base", b"80")
91 91 ('host.org', '80', '/base')
92 92 >>> geturlcgivars(b"http://host.org:8000/base", b"80")
93 93 ('host.org', '8000', '/base')
94 94 >>> geturlcgivars(b'/base', 8000)
95 95 ('', '8000', '/base')
96 96 >>> geturlcgivars(b"base", b'8000')
97 97 ('', '8000', '/base')
98 98 >>> geturlcgivars(b"http://host", b'8000')
99 99 ('host', '8000', '/')
100 100 >>> geturlcgivars(b"http://host/", b'8000')
101 101 ('host', '8000', '/')
102 102 """
103 103 u = util.url(baseurl)
104 104 name = u.host or ''
105 105 if u.port:
106 106 port = u.port
107 107 path = u.path or ""
108 108 if not path.startswith('/'):
109 109 path = '/' + path
110 110
111 111 return name, pycompat.bytestr(port), path
112 112
113 113 class hgwebdir(object):
114 114 """HTTP server for multiple repositories.
115 115
116 116 Given a configuration, different repositories will be served depending
117 117 on the request path.
118 118
119 119 Instances are typically used as WSGI applications.
120 120 """
121 121 def __init__(self, conf, baseui=None):
122 122 self.conf = conf
123 123 self.baseui = baseui
124 124 self.ui = None
125 125 self.lastrefresh = 0
126 126 self.motd = None
127 127 self.refresh()
128 128
129 129 def refresh(self):
130 130 if self.ui:
131 131 refreshinterval = self.ui.configint('web', 'refreshinterval')
132 132 else:
133 133 item = configitems.coreitems['web']['refreshinterval']
134 134 refreshinterval = item.default
135 135
136 136 # refreshinterval <= 0 means to always refresh.
137 137 if (refreshinterval > 0 and
138 138 self.lastrefresh + refreshinterval > time.time()):
139 139 return
140 140
141 141 if self.baseui:
142 142 u = self.baseui.copy()
143 143 else:
144 144 u = uimod.ui.load()
145 145 u.setconfig('ui', 'report_untrusted', 'off', 'hgwebdir')
146 146 u.setconfig('ui', 'nontty', 'true', 'hgwebdir')
147 147 # displaying bundling progress bar while serving feels wrong and may
148 148 # break some wsgi implementations.
149 149 u.setconfig('progress', 'disable', 'true', 'hgweb')
150 150
151 151 if not isinstance(self.conf, (dict, list, tuple)):
152 152 map = {'paths': 'hgweb-paths'}
153 153 if not os.path.exists(self.conf):
154 154 raise error.Abort(_('config file %s not found!') % self.conf)
155 155 u.readconfig(self.conf, remap=map, trust=True)
156 156 paths = []
157 157 for name, ignored in u.configitems('hgweb-paths'):
158 158 for path in u.configlist('hgweb-paths', name):
159 159 paths.append((name, path))
160 160 elif isinstance(self.conf, (list, tuple)):
161 161 paths = self.conf
162 162 elif isinstance(self.conf, dict):
163 163 paths = self.conf.items()
164 164
165 165 repos = findrepos(paths)
166 166 for prefix, root in u.configitems('collections'):
167 167 prefix = util.pconvert(prefix)
168 168 for path in scmutil.walkrepos(root, followsym=True):
169 169 repo = os.path.normpath(path)
170 170 name = util.pconvert(repo)
171 171 if name.startswith(prefix):
172 172 name = name[len(prefix):]
173 173 repos.append((name.lstrip('/'), repo))
174 174
175 175 self.repos = repos
176 176 self.ui = u
177 177 encoding.encoding = self.ui.config('web', 'encoding')
178 178 self.style = self.ui.config('web', 'style')
179 179 self.templatepath = self.ui.config('web', 'templates', untrusted=False)
180 180 self.stripecount = self.ui.config('web', 'stripes')
181 181 if self.stripecount:
182 182 self.stripecount = int(self.stripecount)
183 183 self._baseurl = self.ui.config('web', 'baseurl')
184 184 prefix = self.ui.config('web', 'prefix')
185 185 if prefix.startswith('/'):
186 186 prefix = prefix[1:]
187 187 if prefix.endswith('/'):
188 188 prefix = prefix[:-1]
189 189 self.prefix = prefix
190 190 self.lastrefresh = time.time()
191 191
192 192 def run(self):
193 193 if not encoding.environ.get('GATEWAY_INTERFACE',
194 194 '').startswith("CGI/1."):
195 195 raise RuntimeError("This function is only intended to be "
196 196 "called while running as a CGI script.")
197 197 wsgicgi.launch(self)
198 198
199 199 def __call__(self, env, respond):
200 200 wsgireq = requestmod.wsgirequest(env, respond)
201 201 return self.run_wsgi(wsgireq)
202 202
203 203 def read_allowed(self, ui, wsgireq):
204 204 """Check allow_read and deny_read config options of a repo's ui object
205 205 to determine user permissions. By default, with neither option set (or
206 206 both empty), allow all users to read the repo. There are two ways a
207 207 user can be denied read access: (1) deny_read is not empty, and the
208 208 user is unauthenticated or deny_read contains user (or *), and (2)
209 209 allow_read is not empty and the user is not in allow_read. Return True
210 210 if user is allowed to read the repo, else return False."""
211 211
212 212 user = wsgireq.env.get('REMOTE_USER')
213 213
214 214 deny_read = ui.configlist('web', 'deny_read', untrusted=True)
215 215 if deny_read and (not user or ismember(ui, user, deny_read)):
216 216 return False
217 217
218 218 allow_read = ui.configlist('web', 'allow_read', untrusted=True)
219 219 # by default, allow reading if no allow_read option has been set
220 220 if (not allow_read) or ismember(ui, user, allow_read):
221 221 return True
222 222
223 223 return False
224 224
225 225 def run_wsgi(self, wsgireq):
226 226 profile = self.ui.configbool('profiling', 'enabled')
227 227 with profiling.profile(self.ui, enabled=profile):
228 228 for r in self._runwsgi(wsgireq):
229 229 yield r
230 230
231 231 def _runwsgi(self, wsgireq):
232 232 req = wsgireq.req
233 res = wsgireq.res
233 234
234 235 try:
235 236 self.refresh()
236 237
237 238 csp, nonce = cspvalues(self.ui)
238 239 if csp:
240 res.headers['Content-Security-Policy'] = csp
239 241 wsgireq.headers.append(('Content-Security-Policy', csp))
240 242
241 243 virtual = wsgireq.env.get("PATH_INFO", "").strip('/')
242 244 tmpl = self.templater(wsgireq, nonce)
243 245 ctype = tmpl('mimetype', encoding=encoding.encoding)
244 246 ctype = templater.stringify(ctype)
245 247
248 # Global defaults. These can be overridden by any handler.
249 res.status = '200 Script output follows'
250 res.headers['Content-Type'] = ctype
251
246 252 # a static file
247 253 if virtual.startswith('static/') or 'static' in req.qsparams:
248 254 if virtual.startswith('static/'):
249 255 fname = virtual[7:]
250 256 else:
251 257 fname = req.qsparams['static']
252 258 static = self.ui.config("web", "static", None,
253 259 untrusted=False)
254 260 if not static:
255 261 tp = self.templatepath or templater.templatepaths()
256 262 if isinstance(tp, str):
257 263 tp = [tp]
258 264 static = [os.path.join(p, 'static') for p in tp]
259 staticfile(static, fname, wsgireq)
260 return []
265
266 staticfile(static, fname, res)
267 return res.sendresponse()
261 268
262 269 # top-level index
263 270
264 271 repos = dict(self.repos)
265 272
266 273 if (not virtual or virtual == 'index') and virtual not in repos:
267 274 wsgireq.respond(HTTP_OK, ctype)
268 275 return self.makeindex(wsgireq, tmpl)
269 276
270 277 # nested indexes and hgwebs
271 278
272 279 if virtual.endswith('/index') and virtual not in repos:
273 280 subdir = virtual[:-len('index')]
274 281 if any(r.startswith(subdir) for r in repos):
275 282 wsgireq.respond(HTTP_OK, ctype)
276 283 return self.makeindex(wsgireq, tmpl, subdir)
277 284
278 285 def _virtualdirs():
279 286 # Check the full virtual path, each parent, and the root ('')
280 287 if virtual != '':
281 288 yield virtual
282 289
283 290 for p in util.finddirs(virtual):
284 291 yield p
285 292
286 293 yield ''
287 294
288 295 for virtualrepo in _virtualdirs():
289 296 real = repos.get(virtualrepo)
290 297 if real:
291 298 wsgireq.env['REPO_NAME'] = virtualrepo
292 299 # We have to re-parse because of updated environment
293 300 # variable.
294 301 # TODO this is kind of hacky and we should have a better
295 302 # way of doing this than with REPO_NAME side-effects.
296 303 wsgireq.req = requestmod.parserequestfromenv(
297 304 wsgireq.env, wsgireq.req.bodyfh)
298 305 try:
299 306 # ensure caller gets private copy of ui
300 307 repo = hg.repository(self.ui.copy(), real)
301 308 return hgweb_mod.hgweb(repo).run_wsgi(wsgireq)
302 309 except IOError as inst:
303 310 msg = encoding.strtolocal(inst.strerror)
304 311 raise ErrorResponse(HTTP_SERVER_ERROR, msg)
305 312 except error.RepoError as inst:
306 313 raise ErrorResponse(HTTP_SERVER_ERROR, bytes(inst))
307 314
308 315 # browse subdirectories
309 316 subdir = virtual + '/'
310 317 if [r for r in repos if r.startswith(subdir)]:
311 318 wsgireq.respond(HTTP_OK, ctype)
312 319 return self.makeindex(wsgireq, tmpl, subdir)
313 320
314 321 # prefixes not found
315 322 wsgireq.respond(HTTP_NOT_FOUND, ctype)
316 323 return tmpl("notfound", repo=virtual)
317 324
318 325 except ErrorResponse as err:
319 326 wsgireq.respond(err, ctype)
320 327 return tmpl('error', error=err.message or '')
321 328 finally:
322 329 tmpl = None
323 330
324 331 def makeindex(self, wsgireq, tmpl, subdir=""):
325 332
326 333 def archivelist(ui, nodeid, url):
327 334 allowed = ui.configlist("web", "allow_archive", untrusted=True)
328 335 archives = []
329 336 for typ, spec in hgweb_mod.archivespecs.iteritems():
330 337 if typ in allowed or ui.configbool("web", "allow" + typ,
331 338 untrusted=True):
332 339 archives.append({"type": typ, "extension": spec[2],
333 340 "node": nodeid, "url": url})
334 341 return archives
335 342
336 343 def rawentries(subdir="", **map):
337 344
338 345 descend = self.ui.configbool('web', 'descend')
339 346 collapse = self.ui.configbool('web', 'collapse')
340 347 seenrepos = set()
341 348 seendirs = set()
342 349 for name, path in self.repos:
343 350
344 351 if not name.startswith(subdir):
345 352 continue
346 353 name = name[len(subdir):]
347 354 directory = False
348 355
349 356 if '/' in name:
350 357 if not descend:
351 358 continue
352 359
353 360 nameparts = name.split('/')
354 361 rootname = nameparts[0]
355 362
356 363 if not collapse:
357 364 pass
358 365 elif rootname in seendirs:
359 366 continue
360 367 elif rootname in seenrepos:
361 368 pass
362 369 else:
363 370 directory = True
364 371 name = rootname
365 372
366 373 # redefine the path to refer to the directory
367 374 discarded = '/'.join(nameparts[1:])
368 375
369 376 # remove name parts plus accompanying slash
370 377 path = path[:-len(discarded) - 1]
371 378
372 379 try:
373 380 r = hg.repository(self.ui, path)
374 381 directory = False
375 382 except (IOError, error.RepoError):
376 383 pass
377 384
378 385 parts = [name]
379 386 parts.insert(0, '/' + subdir.rstrip('/'))
380 387 if wsgireq.env['SCRIPT_NAME']:
381 388 parts.insert(0, wsgireq.env['SCRIPT_NAME'])
382 389 url = re.sub(r'/+', '/', '/'.join(parts) + '/')
383 390
384 391 # show either a directory entry or a repository
385 392 if directory:
386 393 # get the directory's time information
387 394 try:
388 395 d = (get_mtime(path), dateutil.makedate()[1])
389 396 except OSError:
390 397 continue
391 398
392 399 # add '/' to the name to make it obvious that
393 400 # the entry is a directory, not a regular repository
394 401 row = {'contact': "",
395 402 'contact_sort': "",
396 403 'name': name + '/',
397 404 'name_sort': name,
398 405 'url': url,
399 406 'description': "",
400 407 'description_sort': "",
401 408 'lastchange': d,
402 409 'lastchange_sort': d[1]-d[0],
403 410 'archives': [],
404 411 'isdirectory': True,
405 412 'labels': [],
406 413 }
407 414
408 415 seendirs.add(name)
409 416 yield row
410 417 continue
411 418
412 419 u = self.ui.copy()
413 420 try:
414 421 u.readconfig(os.path.join(path, '.hg', 'hgrc'))
415 422 except Exception as e:
416 423 u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
417 424 continue
418 425 def get(section, name, default=uimod._unset):
419 426 return u.config(section, name, default, untrusted=True)
420 427
421 428 if u.configbool("web", "hidden", untrusted=True):
422 429 continue
423 430
424 431 if not self.read_allowed(u, wsgireq):
425 432 continue
426 433
427 434 # update time with local timezone
428 435 try:
429 436 r = hg.repository(self.ui, path)
430 437 except IOError:
431 438 u.warn(_('error accessing repository at %s\n') % path)
432 439 continue
433 440 except error.RepoError:
434 441 u.warn(_('error accessing repository at %s\n') % path)
435 442 continue
436 443 try:
437 444 d = (get_mtime(r.spath), dateutil.makedate()[1])
438 445 except OSError:
439 446 continue
440 447
441 448 contact = get_contact(get)
442 449 description = get("web", "description")
443 450 seenrepos.add(name)
444 451 name = get("web", "name", name)
445 452 row = {'contact': contact or "unknown",
446 453 'contact_sort': contact.upper() or "unknown",
447 454 'name': name,
448 455 'name_sort': name,
449 456 'url': url,
450 457 'description': description or "unknown",
451 458 'description_sort': description.upper() or "unknown",
452 459 'lastchange': d,
453 460 'lastchange_sort': d[1]-d[0],
454 461 'archives': archivelist(u, "tip", url),
455 462 'isdirectory': None,
456 463 'labels': u.configlist('web', 'labels', untrusted=True),
457 464 }
458 465
459 466 yield row
460 467
461 468 sortdefault = None, False
462 469 def entries(sortcolumn="", descending=False, subdir="", **map):
463 470 rows = rawentries(subdir=subdir, **map)
464 471
465 472 if sortcolumn and sortdefault != (sortcolumn, descending):
466 473 sortkey = '%s_sort' % sortcolumn
467 474 rows = sorted(rows, key=lambda x: x[sortkey],
468 475 reverse=descending)
469 476 for row, parity in zip(rows, paritygen(self.stripecount)):
470 477 row['parity'] = parity
471 478 yield row
472 479
473 480 self.refresh()
474 481 sortable = ["name", "description", "contact", "lastchange"]
475 482 sortcolumn, descending = sortdefault
476 483 if 'sort' in wsgireq.req.qsparams:
477 484 sortcolum = wsgireq.req.qsparams['sort']
478 485 descending = sortcolumn.startswith('-')
479 486 if descending:
480 487 sortcolumn = sortcolumn[1:]
481 488 if sortcolumn not in sortable:
482 489 sortcolumn = ""
483 490
484 491 sort = [("sort_%s" % column,
485 492 "%s%s" % ((not descending and column == sortcolumn)
486 493 and "-" or "", column))
487 494 for column in sortable]
488 495
489 496 self.refresh()
490 497 self.updatereqenv(wsgireq.env)
491 498
492 499 return tmpl("index", entries=entries, subdir=subdir,
493 500 pathdef=hgweb_mod.makebreadcrumb('/' + subdir, self.prefix),
494 501 sortcolumn=sortcolumn, descending=descending,
495 502 **dict(sort))
496 503
497 504 def templater(self, wsgireq, nonce):
498 505
499 506 def motd(**map):
500 507 if self.motd is not None:
501 508 yield self.motd
502 509 else:
503 510 yield config('web', 'motd')
504 511
505 512 def config(section, name, default=uimod._unset, untrusted=True):
506 513 return self.ui.config(section, name, default, untrusted)
507 514
508 515 self.updatereqenv(wsgireq.env)
509 516
510 517 url = wsgireq.env.get('SCRIPT_NAME', '')
511 518 if not url.endswith('/'):
512 519 url += '/'
513 520
514 521 vars = {}
515 522 styles, (style, mapfile) = hgweb_mod.getstyle(wsgireq.req, config,
516 523 self.templatepath)
517 524 if style == styles[0]:
518 525 vars['style'] = style
519 526
520 527 sessionvars = webutil.sessionvars(vars, r'?')
521 528 logourl = config('web', 'logourl')
522 529 logoimg = config('web', 'logoimg')
523 530 staticurl = config('web', 'staticurl') or url + 'static/'
524 531 if not staticurl.endswith('/'):
525 532 staticurl += '/'
526 533
527 534 defaults = {
528 535 "encoding": encoding.encoding,
529 536 "motd": motd,
530 537 "url": url,
531 538 "logourl": logourl,
532 539 "logoimg": logoimg,
533 540 "staticurl": staticurl,
534 541 "sessionvars": sessionvars,
535 542 "style": style,
536 543 "nonce": nonce,
537 544 }
538 545 tmpl = templater.templater.frommapfile(mapfile, defaults=defaults)
539 546 return tmpl
540 547
541 548 def updatereqenv(self, env):
542 549 if self._baseurl is not None:
543 550 name, port, path = geturlcgivars(self._baseurl, env['SERVER_PORT'])
544 551 env['SERVER_NAME'] = name
545 552 env['SERVER_PORT'] = port
546 553 env['SCRIPT_NAME'] = path
@@ -1,1505 +1,1506 b''
1 1 #
2 2 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from __future__ import absolute_import
9 9
10 10 import copy
11 11 import mimetypes
12 12 import os
13 13 import re
14 14
15 15 from ..i18n import _
16 16 from ..node import hex, nullid, short
17 17
18 18 from .common import (
19 19 ErrorResponse,
20 20 HTTP_FORBIDDEN,
21 21 HTTP_NOT_FOUND,
22 22 HTTP_OK,
23 23 get_contact,
24 24 paritygen,
25 25 staticfile,
26 26 )
27 27
28 28 from .. import (
29 29 archival,
30 30 dagop,
31 31 encoding,
32 32 error,
33 33 graphmod,
34 34 pycompat,
35 35 revset,
36 36 revsetlang,
37 37 scmutil,
38 38 smartset,
39 39 templater,
40 40 util,
41 41 )
42 42
43 43 from . import (
44 44 webutil,
45 45 )
46 46
47 47 __all__ = []
48 48 commands = {}
49 49
50 50 class webcommand(object):
51 51 """Decorator used to register a web command handler.
52 52
53 53 The decorator takes as its positional arguments the name/path the
54 54 command should be accessible under.
55 55
56 56 When called, functions receive as arguments a ``requestcontext``,
57 57 ``wsgirequest``, and a templater instance for generatoring output.
58 58 The functions should populate the ``rctx.res`` object with details
59 59 about the HTTP response.
60 60
61 61 The function can return the ``requestcontext.res`` instance to signal
62 62 that it wants to use this object to generate the response. If an iterable
63 63 is returned, the ``wsgirequest`` instance will be used and the returned
64 64 content will constitute the response body.
65 65
66 66 Usage:
67 67
68 68 @webcommand('mycommand')
69 69 def mycommand(web, req, tmpl):
70 70 pass
71 71 """
72 72
73 73 def __init__(self, name):
74 74 self.name = name
75 75
76 76 def __call__(self, func):
77 77 __all__.append(self.name)
78 78 commands[self.name] = func
79 79 return func
80 80
81 81 @webcommand('log')
82 82 def log(web, req, tmpl):
83 83 """
84 84 /log[/{revision}[/{path}]]
85 85 --------------------------
86 86
87 87 Show repository or file history.
88 88
89 89 For URLs of the form ``/log/{revision}``, a list of changesets starting at
90 90 the specified changeset identifier is shown. If ``{revision}`` is not
91 91 defined, the default is ``tip``. This form is equivalent to the
92 92 ``changelog`` handler.
93 93
94 94 For URLs of the form ``/log/{revision}/{file}``, the history for a specific
95 95 file will be shown. This form is equivalent to the ``filelog`` handler.
96 96 """
97 97
98 98 if req.req.qsparams.get('file'):
99 99 return filelog(web, req, tmpl)
100 100 else:
101 101 return changelog(web, req, tmpl)
102 102
103 103 @webcommand('rawfile')
104 104 def rawfile(web, req, tmpl):
105 105 guessmime = web.configbool('web', 'guessmime')
106 106
107 107 path = webutil.cleanpath(web.repo, req.req.qsparams.get('file', ''))
108 108 if not path:
109 109 return manifest(web, req, tmpl)
110 110
111 111 try:
112 112 fctx = webutil.filectx(web.repo, req)
113 113 except error.LookupError as inst:
114 114 try:
115 115 return manifest(web, req, tmpl)
116 116 except ErrorResponse:
117 117 raise inst
118 118
119 119 path = fctx.path()
120 120 text = fctx.data()
121 121 mt = 'application/binary'
122 122 if guessmime:
123 123 mt = mimetypes.guess_type(path)[0]
124 124 if mt is None:
125 125 if util.binary(text):
126 126 mt = 'application/binary'
127 127 else:
128 128 mt = 'text/plain'
129 129 if mt.startswith('text/'):
130 130 mt += '; charset="%s"' % encoding.encoding
131 131
132 132 web.res.headers['Content-Type'] = mt
133 133 filename = (path.rpartition('/')[-1]
134 134 .replace('\\', '\\\\').replace('"', '\\"'))
135 135 web.res.headers['Content-Disposition'] = 'inline; filename="%s"' % filename
136 136 web.res.setbodybytes(text)
137 137 return web.res
138 138
139 139 def _filerevision(web, req, tmpl, fctx):
140 140 f = fctx.path()
141 141 text = fctx.data()
142 142 parity = paritygen(web.stripecount)
143 143 ishead = fctx.filerev() in fctx.filelog().headrevs()
144 144
145 145 if util.binary(text):
146 146 mt = mimetypes.guess_type(f)[0] or 'application/octet-stream'
147 147 text = '(binary:%s)' % mt
148 148
149 149 def lines():
150 150 for lineno, t in enumerate(text.splitlines(True)):
151 151 yield {"line": t,
152 152 "lineid": "l%d" % (lineno + 1),
153 153 "linenumber": "% 6d" % (lineno + 1),
154 154 "parity": next(parity)}
155 155
156 156 web.res.setbodygen(tmpl(
157 157 'filerevision',
158 158 file=f,
159 159 path=webutil.up(f),
160 160 text=lines(),
161 161 symrev=webutil.symrevorshortnode(req, fctx),
162 162 rename=webutil.renamelink(fctx),
163 163 permissions=fctx.manifest().flags(f),
164 164 ishead=int(ishead),
165 165 **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))))
166 166
167 167 return web.res
168 168
169 169 @webcommand('file')
170 170 def file(web, req, tmpl):
171 171 """
172 172 /file/{revision}[/{path}]
173 173 -------------------------
174 174
175 175 Show information about a directory or file in the repository.
176 176
177 177 Info about the ``path`` given as a URL parameter will be rendered.
178 178
179 179 If ``path`` is a directory, information about the entries in that
180 180 directory will be rendered. This form is equivalent to the ``manifest``
181 181 handler.
182 182
183 183 If ``path`` is a file, information about that file will be shown via
184 184 the ``filerevision`` template.
185 185
186 186 If ``path`` is not defined, information about the root directory will
187 187 be rendered.
188 188 """
189 189 if web.req.qsparams.get('style') == 'raw':
190 190 return rawfile(web, req, tmpl)
191 191
192 192 path = webutil.cleanpath(web.repo, req.req.qsparams.get('file', ''))
193 193 if not path:
194 194 return manifest(web, req, tmpl)
195 195 try:
196 196 return _filerevision(web, req, tmpl, webutil.filectx(web.repo, req))
197 197 except error.LookupError as inst:
198 198 try:
199 199 return manifest(web, req, tmpl)
200 200 except ErrorResponse:
201 201 raise inst
202 202
203 203 def _search(web, req, tmpl):
204 204 MODE_REVISION = 'rev'
205 205 MODE_KEYWORD = 'keyword'
206 206 MODE_REVSET = 'revset'
207 207
208 208 def revsearch(ctx):
209 209 yield ctx
210 210
211 211 def keywordsearch(query):
212 212 lower = encoding.lower
213 213 qw = lower(query).split()
214 214
215 215 def revgen():
216 216 cl = web.repo.changelog
217 217 for i in xrange(len(web.repo) - 1, 0, -100):
218 218 l = []
219 219 for j in cl.revs(max(0, i - 99), i):
220 220 ctx = web.repo[j]
221 221 l.append(ctx)
222 222 l.reverse()
223 223 for e in l:
224 224 yield e
225 225
226 226 for ctx in revgen():
227 227 miss = 0
228 228 for q in qw:
229 229 if not (q in lower(ctx.user()) or
230 230 q in lower(ctx.description()) or
231 231 q in lower(" ".join(ctx.files()))):
232 232 miss = 1
233 233 break
234 234 if miss:
235 235 continue
236 236
237 237 yield ctx
238 238
239 239 def revsetsearch(revs):
240 240 for r in revs:
241 241 yield web.repo[r]
242 242
243 243 searchfuncs = {
244 244 MODE_REVISION: (revsearch, 'exact revision search'),
245 245 MODE_KEYWORD: (keywordsearch, 'literal keyword search'),
246 246 MODE_REVSET: (revsetsearch, 'revset expression search'),
247 247 }
248 248
249 249 def getsearchmode(query):
250 250 try:
251 251 ctx = web.repo[query]
252 252 except (error.RepoError, error.LookupError):
253 253 # query is not an exact revision pointer, need to
254 254 # decide if it's a revset expression or keywords
255 255 pass
256 256 else:
257 257 return MODE_REVISION, ctx
258 258
259 259 revdef = 'reverse(%s)' % query
260 260 try:
261 261 tree = revsetlang.parse(revdef)
262 262 except error.ParseError:
263 263 # can't parse to a revset tree
264 264 return MODE_KEYWORD, query
265 265
266 266 if revsetlang.depth(tree) <= 2:
267 267 # no revset syntax used
268 268 return MODE_KEYWORD, query
269 269
270 270 if any((token, (value or '')[:3]) == ('string', 're:')
271 271 for token, value, pos in revsetlang.tokenize(revdef)):
272 272 return MODE_KEYWORD, query
273 273
274 274 funcsused = revsetlang.funcsused(tree)
275 275 if not funcsused.issubset(revset.safesymbols):
276 276 return MODE_KEYWORD, query
277 277
278 278 mfunc = revset.match(web.repo.ui, revdef, repo=web.repo)
279 279 try:
280 280 revs = mfunc(web.repo)
281 281 return MODE_REVSET, revs
282 282 # ParseError: wrongly placed tokens, wrongs arguments, etc
283 283 # RepoLookupError: no such revision, e.g. in 'revision:'
284 284 # Abort: bookmark/tag not exists
285 285 # LookupError: ambiguous identifier, e.g. in '(bc)' on a large repo
286 286 except (error.ParseError, error.RepoLookupError, error.Abort,
287 287 LookupError):
288 288 return MODE_KEYWORD, query
289 289
290 290 def changelist(**map):
291 291 count = 0
292 292
293 293 for ctx in searchfunc[0](funcarg):
294 294 count += 1
295 295 n = ctx.node()
296 296 showtags = webutil.showtag(web.repo, tmpl, 'changelogtag', n)
297 297 files = webutil.listfilediffs(tmpl, ctx.files(), n, web.maxfiles)
298 298
299 299 yield tmpl('searchentry',
300 300 parity=next(parity),
301 301 changelogtag=showtags,
302 302 files=files,
303 303 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx)))
304 304
305 305 if count >= revcount:
306 306 break
307 307
308 308 query = req.req.qsparams['rev']
309 309 revcount = web.maxchanges
310 310 if 'revcount' in req.req.qsparams:
311 311 try:
312 312 revcount = int(req.req.qsparams.get('revcount', revcount))
313 313 revcount = max(revcount, 1)
314 314 tmpl.defaults['sessionvars']['revcount'] = revcount
315 315 except ValueError:
316 316 pass
317 317
318 318 lessvars = copy.copy(tmpl.defaults['sessionvars'])
319 319 lessvars['revcount'] = max(revcount // 2, 1)
320 320 lessvars['rev'] = query
321 321 morevars = copy.copy(tmpl.defaults['sessionvars'])
322 322 morevars['revcount'] = revcount * 2
323 323 morevars['rev'] = query
324 324
325 325 mode, funcarg = getsearchmode(query)
326 326
327 327 if 'forcekw' in req.req.qsparams:
328 328 showforcekw = ''
329 329 showunforcekw = searchfuncs[mode][1]
330 330 mode = MODE_KEYWORD
331 331 funcarg = query
332 332 else:
333 333 if mode != MODE_KEYWORD:
334 334 showforcekw = searchfuncs[MODE_KEYWORD][1]
335 335 else:
336 336 showforcekw = ''
337 337 showunforcekw = ''
338 338
339 339 searchfunc = searchfuncs[mode]
340 340
341 341 tip = web.repo['tip']
342 342 parity = paritygen(web.stripecount)
343 343
344 344 web.res.setbodygen(tmpl(
345 345 'search',
346 346 query=query,
347 347 node=tip.hex(),
348 348 symrev='tip',
349 349 entries=changelist,
350 350 archives=web.archivelist('tip'),
351 351 morevars=morevars,
352 352 lessvars=lessvars,
353 353 modedesc=searchfunc[1],
354 354 showforcekw=showforcekw,
355 355 showunforcekw=showunforcekw))
356 356
357 357 return web.res
358 358
359 359 @webcommand('changelog')
360 360 def changelog(web, req, tmpl, shortlog=False):
361 361 """
362 362 /changelog[/{revision}]
363 363 -----------------------
364 364
365 365 Show information about multiple changesets.
366 366
367 367 If the optional ``revision`` URL argument is absent, information about
368 368 all changesets starting at ``tip`` will be rendered. If the ``revision``
369 369 argument is present, changesets will be shown starting from the specified
370 370 revision.
371 371
372 372 If ``revision`` is absent, the ``rev`` query string argument may be
373 373 defined. This will perform a search for changesets.
374 374
375 375 The argument for ``rev`` can be a single revision, a revision set,
376 376 or a literal keyword to search for in changeset data (equivalent to
377 377 :hg:`log -k`).
378 378
379 379 The ``revcount`` query string argument defines the maximum numbers of
380 380 changesets to render.
381 381
382 382 For non-searches, the ``changelog`` template will be rendered.
383 383 """
384 384
385 385 query = ''
386 386 if 'node' in req.req.qsparams:
387 387 ctx = webutil.changectx(web.repo, req)
388 388 symrev = webutil.symrevorshortnode(req, ctx)
389 389 elif 'rev' in req.req.qsparams:
390 390 return _search(web, req, tmpl)
391 391 else:
392 392 ctx = web.repo['tip']
393 393 symrev = 'tip'
394 394
395 395 def changelist():
396 396 revs = []
397 397 if pos != -1:
398 398 revs = web.repo.changelog.revs(pos, 0)
399 399 curcount = 0
400 400 for rev in revs:
401 401 curcount += 1
402 402 if curcount > revcount + 1:
403 403 break
404 404
405 405 entry = webutil.changelistentry(web, web.repo[rev], tmpl)
406 406 entry['parity'] = next(parity)
407 407 yield entry
408 408
409 409 if shortlog:
410 410 revcount = web.maxshortchanges
411 411 else:
412 412 revcount = web.maxchanges
413 413
414 414 if 'revcount' in req.req.qsparams:
415 415 try:
416 416 revcount = int(req.req.qsparams.get('revcount', revcount))
417 417 revcount = max(revcount, 1)
418 418 tmpl.defaults['sessionvars']['revcount'] = revcount
419 419 except ValueError:
420 420 pass
421 421
422 422 lessvars = copy.copy(tmpl.defaults['sessionvars'])
423 423 lessvars['revcount'] = max(revcount // 2, 1)
424 424 morevars = copy.copy(tmpl.defaults['sessionvars'])
425 425 morevars['revcount'] = revcount * 2
426 426
427 427 count = len(web.repo)
428 428 pos = ctx.rev()
429 429 parity = paritygen(web.stripecount)
430 430
431 431 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
432 432
433 433 entries = list(changelist())
434 434 latestentry = entries[:1]
435 435 if len(entries) > revcount:
436 436 nextentry = entries[-1:]
437 437 entries = entries[:-1]
438 438 else:
439 439 nextentry = []
440 440
441 441 web.res.setbodygen(tmpl(
442 442 'shortlog' if shortlog else 'changelog',
443 443 changenav=changenav,
444 444 node=ctx.hex(),
445 445 rev=pos,
446 446 symrev=symrev,
447 447 changesets=count,
448 448 entries=entries,
449 449 latestentry=latestentry,
450 450 nextentry=nextentry,
451 451 archives=web.archivelist('tip'),
452 452 revcount=revcount,
453 453 morevars=morevars,
454 454 lessvars=lessvars,
455 455 query=query))
456 456
457 457 return web.res
458 458
459 459 @webcommand('shortlog')
460 460 def shortlog(web, req, tmpl):
461 461 """
462 462 /shortlog
463 463 ---------
464 464
465 465 Show basic information about a set of changesets.
466 466
467 467 This accepts the same parameters as the ``changelog`` handler. The only
468 468 difference is the ``shortlog`` template will be rendered instead of the
469 469 ``changelog`` template.
470 470 """
471 471 return changelog(web, req, tmpl, shortlog=True)
472 472
473 473 @webcommand('changeset')
474 474 def changeset(web, req, tmpl):
475 475 """
476 476 /changeset[/{revision}]
477 477 -----------------------
478 478
479 479 Show information about a single changeset.
480 480
481 481 A URL path argument is the changeset identifier to show. See ``hg help
482 482 revisions`` for possible values. If not defined, the ``tip`` changeset
483 483 will be shown.
484 484
485 485 The ``changeset`` template is rendered. Contents of the ``changesettag``,
486 486 ``changesetbookmark``, ``filenodelink``, ``filenolink``, and the many
487 487 templates related to diffs may all be used to produce the output.
488 488 """
489 489 ctx = webutil.changectx(web.repo, req)
490 490 web.res.setbodygen(tmpl('changeset',
491 491 **webutil.changesetentry(web, req, tmpl, ctx)))
492 492 return web.res
493 493
494 494 rev = webcommand('rev')(changeset)
495 495
496 496 def decodepath(path):
497 497 """Hook for mapping a path in the repository to a path in the
498 498 working copy.
499 499
500 500 Extensions (e.g., largefiles) can override this to remap files in
501 501 the virtual file system presented by the manifest command below."""
502 502 return path
503 503
504 504 @webcommand('manifest')
505 505 def manifest(web, req, tmpl):
506 506 """
507 507 /manifest[/{revision}[/{path}]]
508 508 -------------------------------
509 509
510 510 Show information about a directory.
511 511
512 512 If the URL path arguments are omitted, information about the root
513 513 directory for the ``tip`` changeset will be shown.
514 514
515 515 Because this handler can only show information for directories, it
516 516 is recommended to use the ``file`` handler instead, as it can handle both
517 517 directories and files.
518 518
519 519 The ``manifest`` template will be rendered for this handler.
520 520 """
521 521 if 'node' in req.req.qsparams:
522 522 ctx = webutil.changectx(web.repo, req)
523 523 symrev = webutil.symrevorshortnode(req, ctx)
524 524 else:
525 525 ctx = web.repo['tip']
526 526 symrev = 'tip'
527 527 path = webutil.cleanpath(web.repo, req.req.qsparams.get('file', ''))
528 528 mf = ctx.manifest()
529 529 node = ctx.node()
530 530
531 531 files = {}
532 532 dirs = {}
533 533 parity = paritygen(web.stripecount)
534 534
535 535 if path and path[-1:] != "/":
536 536 path += "/"
537 537 l = len(path)
538 538 abspath = "/" + path
539 539
540 540 for full, n in mf.iteritems():
541 541 # the virtual path (working copy path) used for the full
542 542 # (repository) path
543 543 f = decodepath(full)
544 544
545 545 if f[:l] != path:
546 546 continue
547 547 remain = f[l:]
548 548 elements = remain.split('/')
549 549 if len(elements) == 1:
550 550 files[remain] = full
551 551 else:
552 552 h = dirs # need to retain ref to dirs (root)
553 553 for elem in elements[0:-1]:
554 554 if elem not in h:
555 555 h[elem] = {}
556 556 h = h[elem]
557 557 if len(h) > 1:
558 558 break
559 559 h[None] = None # denotes files present
560 560
561 561 if mf and not files and not dirs:
562 562 raise ErrorResponse(HTTP_NOT_FOUND, 'path not found: ' + path)
563 563
564 564 def filelist(**map):
565 565 for f in sorted(files):
566 566 full = files[f]
567 567
568 568 fctx = ctx.filectx(full)
569 569 yield {"file": full,
570 570 "parity": next(parity),
571 571 "basename": f,
572 572 "date": fctx.date(),
573 573 "size": fctx.size(),
574 574 "permissions": mf.flags(full)}
575 575
576 576 def dirlist(**map):
577 577 for d in sorted(dirs):
578 578
579 579 emptydirs = []
580 580 h = dirs[d]
581 581 while isinstance(h, dict) and len(h) == 1:
582 582 k, v = next(iter(h.items()))
583 583 if v:
584 584 emptydirs.append(k)
585 585 h = v
586 586
587 587 path = "%s%s" % (abspath, d)
588 588 yield {"parity": next(parity),
589 589 "path": path,
590 590 "emptydirs": "/".join(emptydirs),
591 591 "basename": d}
592 592
593 593 web.res.setbodygen(tmpl(
594 594 'manifest',
595 595 symrev=symrev,
596 596 path=abspath,
597 597 up=webutil.up(abspath),
598 598 upparity=next(parity),
599 599 fentries=filelist,
600 600 dentries=dirlist,
601 601 archives=web.archivelist(hex(node)),
602 602 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))))
603 603
604 604 return web.res
605 605
606 606 @webcommand('tags')
607 607 def tags(web, req, tmpl):
608 608 """
609 609 /tags
610 610 -----
611 611
612 612 Show information about tags.
613 613
614 614 No arguments are accepted.
615 615
616 616 The ``tags`` template is rendered.
617 617 """
618 618 i = list(reversed(web.repo.tagslist()))
619 619 parity = paritygen(web.stripecount)
620 620
621 621 def entries(notip, latestonly, **map):
622 622 t = i
623 623 if notip:
624 624 t = [(k, n) for k, n in i if k != "tip"]
625 625 if latestonly:
626 626 t = t[:1]
627 627 for k, n in t:
628 628 yield {"parity": next(parity),
629 629 "tag": k,
630 630 "date": web.repo[n].date(),
631 631 "node": hex(n)}
632 632
633 633 web.res.setbodygen(tmpl(
634 634 'tags',
635 635 node=hex(web.repo.changelog.tip()),
636 636 entries=lambda **x: entries(False, False, **x),
637 637 entriesnotip=lambda **x: entries(True, False, **x),
638 638 latestentry=lambda **x: entries(True, True, **x)))
639 639
640 640 return web.res
641 641
642 642 @webcommand('bookmarks')
643 643 def bookmarks(web, req, tmpl):
644 644 """
645 645 /bookmarks
646 646 ----------
647 647
648 648 Show information about bookmarks.
649 649
650 650 No arguments are accepted.
651 651
652 652 The ``bookmarks`` template is rendered.
653 653 """
654 654 i = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
655 655 sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
656 656 i = sorted(i, key=sortkey, reverse=True)
657 657 parity = paritygen(web.stripecount)
658 658
659 659 def entries(latestonly, **map):
660 660 t = i
661 661 if latestonly:
662 662 t = i[:1]
663 663 for k, n in t:
664 664 yield {"parity": next(parity),
665 665 "bookmark": k,
666 666 "date": web.repo[n].date(),
667 667 "node": hex(n)}
668 668
669 669 if i:
670 670 latestrev = i[0][1]
671 671 else:
672 672 latestrev = -1
673 673
674 674 web.res.setbodygen(tmpl(
675 675 'bookmarks',
676 676 node=hex(web.repo.changelog.tip()),
677 677 lastchange=[{'date': web.repo[latestrev].date()}],
678 678 entries=lambda **x: entries(latestonly=False, **x),
679 679 latestentry=lambda **x: entries(latestonly=True, **x)))
680 680
681 681 return web.res
682 682
683 683 @webcommand('branches')
684 684 def branches(web, req, tmpl):
685 685 """
686 686 /branches
687 687 ---------
688 688
689 689 Show information about branches.
690 690
691 691 All known branches are contained in the output, even closed branches.
692 692
693 693 No arguments are accepted.
694 694
695 695 The ``branches`` template is rendered.
696 696 """
697 697 entries = webutil.branchentries(web.repo, web.stripecount)
698 698 latestentry = webutil.branchentries(web.repo, web.stripecount, 1)
699 699
700 700 web.res.setbodygen(tmpl(
701 701 'branches',
702 702 node=hex(web.repo.changelog.tip()),
703 703 entries=entries,
704 704 latestentry=latestentry))
705 705
706 706 return web.res
707 707
708 708 @webcommand('summary')
709 709 def summary(web, req, tmpl):
710 710 """
711 711 /summary
712 712 --------
713 713
714 714 Show a summary of repository state.
715 715
716 716 Information about the latest changesets, bookmarks, tags, and branches
717 717 is captured by this handler.
718 718
719 719 The ``summary`` template is rendered.
720 720 """
721 721 i = reversed(web.repo.tagslist())
722 722
723 723 def tagentries(**map):
724 724 parity = paritygen(web.stripecount)
725 725 count = 0
726 726 for k, n in i:
727 727 if k == "tip": # skip tip
728 728 continue
729 729
730 730 count += 1
731 731 if count > 10: # limit to 10 tags
732 732 break
733 733
734 734 yield tmpl("tagentry",
735 735 parity=next(parity),
736 736 tag=k,
737 737 node=hex(n),
738 738 date=web.repo[n].date())
739 739
740 740 def bookmarks(**map):
741 741 parity = paritygen(web.stripecount)
742 742 marks = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
743 743 sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
744 744 marks = sorted(marks, key=sortkey, reverse=True)
745 745 for k, n in marks[:10]: # limit to 10 bookmarks
746 746 yield {'parity': next(parity),
747 747 'bookmark': k,
748 748 'date': web.repo[n].date(),
749 749 'node': hex(n)}
750 750
751 751 def changelist(**map):
752 752 parity = paritygen(web.stripecount, offset=start - end)
753 753 l = [] # build a list in forward order for efficiency
754 754 revs = []
755 755 if start < end:
756 756 revs = web.repo.changelog.revs(start, end - 1)
757 757 for i in revs:
758 758 ctx = web.repo[i]
759 759
760 760 l.append(tmpl(
761 761 'shortlogentry',
762 762 parity=next(parity),
763 763 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))))
764 764
765 765 for entry in reversed(l):
766 766 yield entry
767 767
768 768 tip = web.repo['tip']
769 769 count = len(web.repo)
770 770 start = max(0, count - web.maxchanges)
771 771 end = min(count, start + web.maxchanges)
772 772
773 773 desc = web.config("web", "description")
774 774 if not desc:
775 775 desc = 'unknown'
776 776
777 777 web.res.setbodygen(tmpl(
778 778 'summary',
779 779 desc=desc,
780 780 owner=get_contact(web.config) or 'unknown',
781 781 lastchange=tip.date(),
782 782 tags=tagentries,
783 783 bookmarks=bookmarks,
784 784 branches=webutil.branchentries(web.repo, web.stripecount, 10),
785 785 shortlog=changelist,
786 786 node=tip.hex(),
787 787 symrev='tip',
788 788 archives=web.archivelist('tip'),
789 789 labels=web.configlist('web', 'labels')))
790 790
791 791 return web.res
792 792
793 793 @webcommand('filediff')
794 794 def filediff(web, req, tmpl):
795 795 """
796 796 /diff/{revision}/{path}
797 797 -----------------------
798 798
799 799 Show how a file changed in a particular commit.
800 800
801 801 The ``filediff`` template is rendered.
802 802
803 803 This handler is registered under both the ``/diff`` and ``/filediff``
804 804 paths. ``/diff`` is used in modern code.
805 805 """
806 806 fctx, ctx = None, None
807 807 try:
808 808 fctx = webutil.filectx(web.repo, req)
809 809 except LookupError:
810 810 ctx = webutil.changectx(web.repo, req)
811 811 path = webutil.cleanpath(web.repo, req.req.qsparams['file'])
812 812 if path not in ctx.files():
813 813 raise
814 814
815 815 if fctx is not None:
816 816 path = fctx.path()
817 817 ctx = fctx.changectx()
818 818 basectx = ctx.p1()
819 819
820 820 style = web.config('web', 'style')
821 821 if 'style' in req.req.qsparams:
822 822 style = req.req.qsparams['style']
823 823
824 824 diffs = webutil.diffs(web, tmpl, ctx, basectx, [path], style)
825 825 if fctx is not None:
826 826 rename = webutil.renamelink(fctx)
827 827 ctx = fctx
828 828 else:
829 829 rename = []
830 830 ctx = ctx
831 831
832 832 web.res.setbodygen(tmpl(
833 833 'filediff',
834 834 file=path,
835 835 symrev=webutil.symrevorshortnode(req, ctx),
836 836 rename=rename,
837 837 diff=diffs,
838 838 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))))
839 839
840 840 return web.res
841 841
842 842 diff = webcommand('diff')(filediff)
843 843
844 844 @webcommand('comparison')
845 845 def comparison(web, req, tmpl):
846 846 """
847 847 /comparison/{revision}/{path}
848 848 -----------------------------
849 849
850 850 Show a comparison between the old and new versions of a file from changes
851 851 made on a particular revision.
852 852
853 853 This is similar to the ``diff`` handler. However, this form features
854 854 a split or side-by-side diff rather than a unified diff.
855 855
856 856 The ``context`` query string argument can be used to control the lines of
857 857 context in the diff.
858 858
859 859 The ``filecomparison`` template is rendered.
860 860 """
861 861 ctx = webutil.changectx(web.repo, req)
862 862 if 'file' not in req.req.qsparams:
863 863 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
864 864 path = webutil.cleanpath(web.repo, req.req.qsparams['file'])
865 865
866 866 parsecontext = lambda v: v == 'full' and -1 or int(v)
867 867 if 'context' in req.req.qsparams:
868 868 context = parsecontext(req.req.qsparams['context'])
869 869 else:
870 870 context = parsecontext(web.config('web', 'comparisoncontext', '5'))
871 871
872 872 def filelines(f):
873 873 if f.isbinary():
874 874 mt = mimetypes.guess_type(f.path())[0]
875 875 if not mt:
876 876 mt = 'application/octet-stream'
877 877 return [_('(binary file %s, hash: %s)') % (mt, hex(f.filenode()))]
878 878 return f.data().splitlines()
879 879
880 880 fctx = None
881 881 parent = ctx.p1()
882 882 leftrev = parent.rev()
883 883 leftnode = parent.node()
884 884 rightrev = ctx.rev()
885 885 rightnode = ctx.node()
886 886 if path in ctx:
887 887 fctx = ctx[path]
888 888 rightlines = filelines(fctx)
889 889 if path not in parent:
890 890 leftlines = ()
891 891 else:
892 892 pfctx = parent[path]
893 893 leftlines = filelines(pfctx)
894 894 else:
895 895 rightlines = ()
896 896 pfctx = ctx.parents()[0][path]
897 897 leftlines = filelines(pfctx)
898 898
899 899 comparison = webutil.compare(tmpl, context, leftlines, rightlines)
900 900 if fctx is not None:
901 901 rename = webutil.renamelink(fctx)
902 902 ctx = fctx
903 903 else:
904 904 rename = []
905 905 ctx = ctx
906 906
907 907 web.res.setbodygen(tmpl(
908 908 'filecomparison',
909 909 file=path,
910 910 symrev=webutil.symrevorshortnode(req, ctx),
911 911 rename=rename,
912 912 leftrev=leftrev,
913 913 leftnode=hex(leftnode),
914 914 rightrev=rightrev,
915 915 rightnode=hex(rightnode),
916 916 comparison=comparison,
917 917 **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))))
918 918
919 919 return web.res
920 920
921 921 @webcommand('annotate')
922 922 def annotate(web, req, tmpl):
923 923 """
924 924 /annotate/{revision}/{path}
925 925 ---------------------------
926 926
927 927 Show changeset information for each line in a file.
928 928
929 929 The ``ignorews``, ``ignorewsamount``, ``ignorewseol``, and
930 930 ``ignoreblanklines`` query string arguments have the same meaning as
931 931 their ``[annotate]`` config equivalents. It uses the hgrc boolean
932 932 parsing logic to interpret the value. e.g. ``0`` and ``false`` are
933 933 false and ``1`` and ``true`` are true. If not defined, the server
934 934 default settings are used.
935 935
936 936 The ``fileannotate`` template is rendered.
937 937 """
938 938 fctx = webutil.filectx(web.repo, req)
939 939 f = fctx.path()
940 940 parity = paritygen(web.stripecount)
941 941 ishead = fctx.filerev() in fctx.filelog().headrevs()
942 942
943 943 # parents() is called once per line and several lines likely belong to
944 944 # same revision. So it is worth caching.
945 945 # TODO there are still redundant operations within basefilectx.parents()
946 946 # and from the fctx.annotate() call itself that could be cached.
947 947 parentscache = {}
948 948 def parents(f):
949 949 rev = f.rev()
950 950 if rev not in parentscache:
951 951 parentscache[rev] = []
952 952 for p in f.parents():
953 953 entry = {
954 954 'node': p.hex(),
955 955 'rev': p.rev(),
956 956 }
957 957 parentscache[rev].append(entry)
958 958
959 959 for p in parentscache[rev]:
960 960 yield p
961 961
962 962 def annotate(**map):
963 963 if fctx.isbinary():
964 964 mt = (mimetypes.guess_type(fctx.path())[0]
965 965 or 'application/octet-stream')
966 966 lines = [((fctx.filectx(fctx.filerev()), 1), '(binary:%s)' % mt)]
967 967 else:
968 968 lines = webutil.annotate(req, fctx, web.repo.ui)
969 969
970 970 previousrev = None
971 971 blockparitygen = paritygen(1)
972 972 for lineno, (aline, l) in enumerate(lines):
973 973 f = aline.fctx
974 974 rev = f.rev()
975 975 if rev != previousrev:
976 976 blockhead = True
977 977 blockparity = next(blockparitygen)
978 978 else:
979 979 blockhead = None
980 980 previousrev = rev
981 981 yield {"parity": next(parity),
982 982 "node": f.hex(),
983 983 "rev": rev,
984 984 "author": f.user(),
985 985 "parents": parents(f),
986 986 "desc": f.description(),
987 987 "extra": f.extra(),
988 988 "file": f.path(),
989 989 "blockhead": blockhead,
990 990 "blockparity": blockparity,
991 991 "targetline": aline.lineno,
992 992 "line": l,
993 993 "lineno": lineno + 1,
994 994 "lineid": "l%d" % (lineno + 1),
995 995 "linenumber": "% 6d" % (lineno + 1),
996 996 "revdate": f.date()}
997 997
998 998 diffopts = webutil.difffeatureopts(req, web.repo.ui, 'annotate')
999 999 diffopts = {k: getattr(diffopts, k) for k in diffopts.defaults}
1000 1000
1001 1001 web.res.setbodygen(tmpl(
1002 1002 'fileannotate',
1003 1003 file=f,
1004 1004 annotate=annotate,
1005 1005 path=webutil.up(f),
1006 1006 symrev=webutil.symrevorshortnode(req, fctx),
1007 1007 rename=webutil.renamelink(fctx),
1008 1008 permissions=fctx.manifest().flags(f),
1009 1009 ishead=int(ishead),
1010 1010 diffopts=diffopts,
1011 1011 **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))))
1012 1012
1013 1013 return web.res
1014 1014
1015 1015 @webcommand('filelog')
1016 1016 def filelog(web, req, tmpl):
1017 1017 """
1018 1018 /filelog/{revision}/{path}
1019 1019 --------------------------
1020 1020
1021 1021 Show information about the history of a file in the repository.
1022 1022
1023 1023 The ``revcount`` query string argument can be defined to control the
1024 1024 maximum number of entries to show.
1025 1025
1026 1026 The ``filelog`` template will be rendered.
1027 1027 """
1028 1028
1029 1029 try:
1030 1030 fctx = webutil.filectx(web.repo, req)
1031 1031 f = fctx.path()
1032 1032 fl = fctx.filelog()
1033 1033 except error.LookupError:
1034 1034 f = webutil.cleanpath(web.repo, req.req.qsparams['file'])
1035 1035 fl = web.repo.file(f)
1036 1036 numrevs = len(fl)
1037 1037 if not numrevs: # file doesn't exist at all
1038 1038 raise
1039 1039 rev = webutil.changectx(web.repo, req).rev()
1040 1040 first = fl.linkrev(0)
1041 1041 if rev < first: # current rev is from before file existed
1042 1042 raise
1043 1043 frev = numrevs - 1
1044 1044 while fl.linkrev(frev) > rev:
1045 1045 frev -= 1
1046 1046 fctx = web.repo.filectx(f, fl.linkrev(frev))
1047 1047
1048 1048 revcount = web.maxshortchanges
1049 1049 if 'revcount' in req.req.qsparams:
1050 1050 try:
1051 1051 revcount = int(req.req.qsparams.get('revcount', revcount))
1052 1052 revcount = max(revcount, 1)
1053 1053 tmpl.defaults['sessionvars']['revcount'] = revcount
1054 1054 except ValueError:
1055 1055 pass
1056 1056
1057 1057 lrange = webutil.linerange(req)
1058 1058
1059 1059 lessvars = copy.copy(tmpl.defaults['sessionvars'])
1060 1060 lessvars['revcount'] = max(revcount // 2, 1)
1061 1061 morevars = copy.copy(tmpl.defaults['sessionvars'])
1062 1062 morevars['revcount'] = revcount * 2
1063 1063
1064 1064 patch = 'patch' in req.req.qsparams
1065 1065 if patch:
1066 1066 lessvars['patch'] = morevars['patch'] = req.req.qsparams['patch']
1067 1067 descend = 'descend' in req.req.qsparams
1068 1068 if descend:
1069 1069 lessvars['descend'] = morevars['descend'] = req.req.qsparams['descend']
1070 1070
1071 1071 count = fctx.filerev() + 1
1072 1072 start = max(0, count - revcount) # first rev on this page
1073 1073 end = min(count, start + revcount) # last rev on this page
1074 1074 parity = paritygen(web.stripecount, offset=start - end)
1075 1075
1076 1076 repo = web.repo
1077 1077 revs = fctx.filelog().revs(start, end - 1)
1078 1078 entries = []
1079 1079
1080 1080 diffstyle = web.config('web', 'style')
1081 1081 if 'style' in req.req.qsparams:
1082 1082 diffstyle = req.req.qsparams['style']
1083 1083
1084 1084 def diff(fctx, linerange=None):
1085 1085 ctx = fctx.changectx()
1086 1086 basectx = ctx.p1()
1087 1087 path = fctx.path()
1088 1088 return webutil.diffs(web, tmpl, ctx, basectx, [path], diffstyle,
1089 1089 linerange=linerange,
1090 1090 lineidprefix='%s-' % ctx.hex()[:12])
1091 1091
1092 1092 linerange = None
1093 1093 if lrange is not None:
1094 1094 linerange = webutil.formatlinerange(*lrange)
1095 1095 # deactivate numeric nav links when linerange is specified as this
1096 1096 # would required a dedicated "revnav" class
1097 1097 nav = None
1098 1098 if descend:
1099 1099 it = dagop.blockdescendants(fctx, *lrange)
1100 1100 else:
1101 1101 it = dagop.blockancestors(fctx, *lrange)
1102 1102 for i, (c, lr) in enumerate(it, 1):
1103 1103 diffs = None
1104 1104 if patch:
1105 1105 diffs = diff(c, linerange=lr)
1106 1106 # follow renames accross filtered (not in range) revisions
1107 1107 path = c.path()
1108 1108 entries.append(dict(
1109 1109 parity=next(parity),
1110 1110 filerev=c.rev(),
1111 1111 file=path,
1112 1112 diff=diffs,
1113 1113 linerange=webutil.formatlinerange(*lr),
1114 1114 **pycompat.strkwargs(webutil.commonentry(repo, c))))
1115 1115 if i == revcount:
1116 1116 break
1117 1117 lessvars['linerange'] = webutil.formatlinerange(*lrange)
1118 1118 morevars['linerange'] = lessvars['linerange']
1119 1119 else:
1120 1120 for i in revs:
1121 1121 iterfctx = fctx.filectx(i)
1122 1122 diffs = None
1123 1123 if patch:
1124 1124 diffs = diff(iterfctx)
1125 1125 entries.append(dict(
1126 1126 parity=next(parity),
1127 1127 filerev=i,
1128 1128 file=f,
1129 1129 diff=diffs,
1130 1130 rename=webutil.renamelink(iterfctx),
1131 1131 **pycompat.strkwargs(webutil.commonentry(repo, iterfctx))))
1132 1132 entries.reverse()
1133 1133 revnav = webutil.filerevnav(web.repo, fctx.path())
1134 1134 nav = revnav.gen(end - 1, revcount, count)
1135 1135
1136 1136 latestentry = entries[:1]
1137 1137
1138 1138 web.res.setbodygen(tmpl(
1139 1139 'filelog',
1140 1140 file=f,
1141 1141 nav=nav,
1142 1142 symrev=webutil.symrevorshortnode(req, fctx),
1143 1143 entries=entries,
1144 1144 descend=descend,
1145 1145 patch=patch,
1146 1146 latestentry=latestentry,
1147 1147 linerange=linerange,
1148 1148 revcount=revcount,
1149 1149 morevars=morevars,
1150 1150 lessvars=lessvars,
1151 1151 **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))))
1152 1152
1153 1153 return web.res
1154 1154
1155 1155 @webcommand('archive')
1156 1156 def archive(web, req, tmpl):
1157 1157 """
1158 1158 /archive/{revision}.{format}[/{path}]
1159 1159 -------------------------------------
1160 1160
1161 1161 Obtain an archive of repository content.
1162 1162
1163 1163 The content and type of the archive is defined by a URL path parameter.
1164 1164 ``format`` is the file extension of the archive type to be generated. e.g.
1165 1165 ``zip`` or ``tar.bz2``. Not all archive types may be allowed by your
1166 1166 server configuration.
1167 1167
1168 1168 The optional ``path`` URL parameter controls content to include in the
1169 1169 archive. If omitted, every file in the specified revision is present in the
1170 1170 archive. If included, only the specified file or contents of the specified
1171 1171 directory will be included in the archive.
1172 1172
1173 1173 No template is used for this handler. Raw, binary content is generated.
1174 1174 """
1175 1175
1176 1176 type_ = req.req.qsparams.get('type')
1177 1177 allowed = web.configlist("web", "allow_archive")
1178 1178 key = req.req.qsparams['node']
1179 1179
1180 1180 if type_ not in web.archivespecs:
1181 1181 msg = 'Unsupported archive type: %s' % type_
1182 1182 raise ErrorResponse(HTTP_NOT_FOUND, msg)
1183 1183
1184 1184 if not ((type_ in allowed or
1185 1185 web.configbool("web", "allow" + type_))):
1186 1186 msg = 'Archive type not allowed: %s' % type_
1187 1187 raise ErrorResponse(HTTP_FORBIDDEN, msg)
1188 1188
1189 1189 reponame = re.sub(br"\W+", "-", os.path.basename(web.reponame))
1190 1190 cnode = web.repo.lookup(key)
1191 1191 arch_version = key
1192 1192 if cnode == key or key == 'tip':
1193 1193 arch_version = short(cnode)
1194 1194 name = "%s-%s" % (reponame, arch_version)
1195 1195
1196 1196 ctx = webutil.changectx(web.repo, req)
1197 1197 pats = []
1198 1198 match = scmutil.match(ctx, [])
1199 1199 file = req.req.qsparams.get('file')
1200 1200 if file:
1201 1201 pats = ['path:' + file]
1202 1202 match = scmutil.match(ctx, pats, default='path')
1203 1203 if pats:
1204 1204 files = [f for f in ctx.manifest().keys() if match(f)]
1205 1205 if not files:
1206 1206 raise ErrorResponse(HTTP_NOT_FOUND,
1207 1207 'file(s) not found: %s' % file)
1208 1208
1209 1209 mimetype, artype, extension, encoding = web.archivespecs[type_]
1210 1210 headers = [
1211 1211 ('Content-Disposition', 'attachment; filename=%s%s' % (name, extension))
1212 1212 ]
1213 1213 if encoding:
1214 1214 headers.append(('Content-Encoding', encoding))
1215 1215 req.headers.extend(headers)
1216 1216 req.respond(HTTP_OK, mimetype)
1217 1217
1218 1218 archival.archive(web.repo, req, cnode, artype, prefix=name,
1219 1219 matchfn=match,
1220 1220 subrepos=web.configbool("web", "archivesubrepos"))
1221 1221 return []
1222 1222
1223 1223
1224 1224 @webcommand('static')
1225 1225 def static(web, req, tmpl):
1226 1226 fname = req.req.qsparams['file']
1227 1227 # a repo owner may set web.static in .hg/hgrc to get any file
1228 1228 # readable by the user running the CGI script
1229 1229 static = web.config("web", "static", None, untrusted=False)
1230 1230 if not static:
1231 1231 tp = web.templatepath or templater.templatepaths()
1232 1232 if isinstance(tp, str):
1233 1233 tp = [tp]
1234 1234 static = [os.path.join(p, 'static') for p in tp]
1235 staticfile(static, fname, req)
1236 return []
1235
1236 staticfile(static, fname, web.res)
1237 return web.res
1237 1238
1238 1239 @webcommand('graph')
1239 1240 def graph(web, req, tmpl):
1240 1241 """
1241 1242 /graph[/{revision}]
1242 1243 -------------------
1243 1244
1244 1245 Show information about the graphical topology of the repository.
1245 1246
1246 1247 Information rendered by this handler can be used to create visual
1247 1248 representations of repository topology.
1248 1249
1249 1250 The ``revision`` URL parameter controls the starting changeset. If it's
1250 1251 absent, the default is ``tip``.
1251 1252
1252 1253 The ``revcount`` query string argument can define the number of changesets
1253 1254 to show information for.
1254 1255
1255 1256 The ``graphtop`` query string argument can specify the starting changeset
1256 1257 for producing ``jsdata`` variable that is used for rendering graph in
1257 1258 JavaScript. By default it has the same value as ``revision``.
1258 1259
1259 1260 This handler will render the ``graph`` template.
1260 1261 """
1261 1262
1262 1263 if 'node' in req.req.qsparams:
1263 1264 ctx = webutil.changectx(web.repo, req)
1264 1265 symrev = webutil.symrevorshortnode(req, ctx)
1265 1266 else:
1266 1267 ctx = web.repo['tip']
1267 1268 symrev = 'tip'
1268 1269 rev = ctx.rev()
1269 1270
1270 1271 bg_height = 39
1271 1272 revcount = web.maxshortchanges
1272 1273 if 'revcount' in req.req.qsparams:
1273 1274 try:
1274 1275 revcount = int(req.req.qsparams.get('revcount', revcount))
1275 1276 revcount = max(revcount, 1)
1276 1277 tmpl.defaults['sessionvars']['revcount'] = revcount
1277 1278 except ValueError:
1278 1279 pass
1279 1280
1280 1281 lessvars = copy.copy(tmpl.defaults['sessionvars'])
1281 1282 lessvars['revcount'] = max(revcount // 2, 1)
1282 1283 morevars = copy.copy(tmpl.defaults['sessionvars'])
1283 1284 morevars['revcount'] = revcount * 2
1284 1285
1285 1286 graphtop = req.req.qsparams.get('graphtop', ctx.hex())
1286 1287 graphvars = copy.copy(tmpl.defaults['sessionvars'])
1287 1288 graphvars['graphtop'] = graphtop
1288 1289
1289 1290 count = len(web.repo)
1290 1291 pos = rev
1291 1292
1292 1293 uprev = min(max(0, count - 1), rev + revcount)
1293 1294 downrev = max(0, rev - revcount)
1294 1295 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
1295 1296
1296 1297 tree = []
1297 1298 nextentry = []
1298 1299 lastrev = 0
1299 1300 if pos != -1:
1300 1301 allrevs = web.repo.changelog.revs(pos, 0)
1301 1302 revs = []
1302 1303 for i in allrevs:
1303 1304 revs.append(i)
1304 1305 if len(revs) >= revcount + 1:
1305 1306 break
1306 1307
1307 1308 if len(revs) > revcount:
1308 1309 nextentry = [webutil.commonentry(web.repo, web.repo[revs[-1]])]
1309 1310 revs = revs[:-1]
1310 1311
1311 1312 lastrev = revs[-1]
1312 1313
1313 1314 # We have to feed a baseset to dagwalker as it is expecting smartset
1314 1315 # object. This does not have a big impact on hgweb performance itself
1315 1316 # since hgweb graphing code is not itself lazy yet.
1316 1317 dag = graphmod.dagwalker(web.repo, smartset.baseset(revs))
1317 1318 # As we said one line above... not lazy.
1318 1319 tree = list(item for item in graphmod.colored(dag, web.repo)
1319 1320 if item[1] == graphmod.CHANGESET)
1320 1321
1321 1322 def nodecurrent(ctx):
1322 1323 wpnodes = web.repo.dirstate.parents()
1323 1324 if wpnodes[1] == nullid:
1324 1325 wpnodes = wpnodes[:1]
1325 1326 if ctx.node() in wpnodes:
1326 1327 return '@'
1327 1328 return ''
1328 1329
1329 1330 def nodesymbol(ctx):
1330 1331 if ctx.obsolete():
1331 1332 return 'x'
1332 1333 elif ctx.isunstable():
1333 1334 return '*'
1334 1335 elif ctx.closesbranch():
1335 1336 return '_'
1336 1337 else:
1337 1338 return 'o'
1338 1339
1339 1340 def fulltree():
1340 1341 pos = web.repo[graphtop].rev()
1341 1342 tree = []
1342 1343 if pos != -1:
1343 1344 revs = web.repo.changelog.revs(pos, lastrev)
1344 1345 dag = graphmod.dagwalker(web.repo, smartset.baseset(revs))
1345 1346 tree = list(item for item in graphmod.colored(dag, web.repo)
1346 1347 if item[1] == graphmod.CHANGESET)
1347 1348 return tree
1348 1349
1349 1350 def jsdata():
1350 1351 return [{'node': pycompat.bytestr(ctx),
1351 1352 'graphnode': nodecurrent(ctx) + nodesymbol(ctx),
1352 1353 'vertex': vtx,
1353 1354 'edges': edges}
1354 1355 for (id, type, ctx, vtx, edges) in fulltree()]
1355 1356
1356 1357 def nodes():
1357 1358 parity = paritygen(web.stripecount)
1358 1359 for row, (id, type, ctx, vtx, edges) in enumerate(tree):
1359 1360 entry = webutil.commonentry(web.repo, ctx)
1360 1361 edgedata = [{'col': edge[0],
1361 1362 'nextcol': edge[1],
1362 1363 'color': (edge[2] - 1) % 6 + 1,
1363 1364 'width': edge[3],
1364 1365 'bcolor': edge[4]}
1365 1366 for edge in edges]
1366 1367
1367 1368 entry.update({'col': vtx[0],
1368 1369 'color': (vtx[1] - 1) % 6 + 1,
1369 1370 'parity': next(parity),
1370 1371 'edges': edgedata,
1371 1372 'row': row,
1372 1373 'nextrow': row + 1})
1373 1374
1374 1375 yield entry
1375 1376
1376 1377 rows = len(tree)
1377 1378
1378 1379 web.res.setbodygen(tmpl(
1379 1380 'graph',
1380 1381 rev=rev,
1381 1382 symrev=symrev,
1382 1383 revcount=revcount,
1383 1384 uprev=uprev,
1384 1385 lessvars=lessvars,
1385 1386 morevars=morevars,
1386 1387 downrev=downrev,
1387 1388 graphvars=graphvars,
1388 1389 rows=rows,
1389 1390 bg_height=bg_height,
1390 1391 changesets=count,
1391 1392 nextentry=nextentry,
1392 1393 jsdata=lambda **x: jsdata(),
1393 1394 nodes=lambda **x: nodes(),
1394 1395 node=ctx.hex(),
1395 1396 changenav=changenav))
1396 1397
1397 1398 return web.res
1398 1399
1399 1400 def _getdoc(e):
1400 1401 doc = e[0].__doc__
1401 1402 if doc:
1402 1403 doc = _(doc).partition('\n')[0]
1403 1404 else:
1404 1405 doc = _('(no help text available)')
1405 1406 return doc
1406 1407
1407 1408 @webcommand('help')
1408 1409 def help(web, req, tmpl):
1409 1410 """
1410 1411 /help[/{topic}]
1411 1412 ---------------
1412 1413
1413 1414 Render help documentation.
1414 1415
1415 1416 This web command is roughly equivalent to :hg:`help`. If a ``topic``
1416 1417 is defined, that help topic will be rendered. If not, an index of
1417 1418 available help topics will be rendered.
1418 1419
1419 1420 The ``help`` template will be rendered when requesting help for a topic.
1420 1421 ``helptopics`` will be rendered for the index of help topics.
1421 1422 """
1422 1423 from .. import commands, help as helpmod # avoid cycle
1423 1424
1424 1425 topicname = req.req.qsparams.get('node')
1425 1426 if not topicname:
1426 1427 def topics(**map):
1427 1428 for entries, summary, _doc in helpmod.helptable:
1428 1429 yield {'topic': entries[0], 'summary': summary}
1429 1430
1430 1431 early, other = [], []
1431 1432 primary = lambda s: s.partition('|')[0]
1432 1433 for c, e in commands.table.iteritems():
1433 1434 doc = _getdoc(e)
1434 1435 if 'DEPRECATED' in doc or c.startswith('debug'):
1435 1436 continue
1436 1437 cmd = primary(c)
1437 1438 if cmd.startswith('^'):
1438 1439 early.append((cmd[1:], doc))
1439 1440 else:
1440 1441 other.append((cmd, doc))
1441 1442
1442 1443 early.sort()
1443 1444 other.sort()
1444 1445
1445 1446 def earlycommands(**map):
1446 1447 for c, doc in early:
1447 1448 yield {'topic': c, 'summary': doc}
1448 1449
1449 1450 def othercommands(**map):
1450 1451 for c, doc in other:
1451 1452 yield {'topic': c, 'summary': doc}
1452 1453
1453 1454 web.res.setbodygen(tmpl(
1454 1455 'helptopics',
1455 1456 topics=topics,
1456 1457 earlycommands=earlycommands,
1457 1458 othercommands=othercommands,
1458 1459 title='Index'))
1459 1460 return web.res
1460 1461
1461 1462 # Render an index of sub-topics.
1462 1463 if topicname in helpmod.subtopics:
1463 1464 topics = []
1464 1465 for entries, summary, _doc in helpmod.subtopics[topicname]:
1465 1466 topics.append({
1466 1467 'topic': '%s.%s' % (topicname, entries[0]),
1467 1468 'basename': entries[0],
1468 1469 'summary': summary,
1469 1470 })
1470 1471
1471 1472 web.res.setbodygen(tmpl(
1472 1473 'helptopics',
1473 1474 topics=topics,
1474 1475 title=topicname,
1475 1476 subindex=True))
1476 1477 return web.res
1477 1478
1478 1479 u = webutil.wsgiui.load()
1479 1480 u.verbose = True
1480 1481
1481 1482 # Render a page from a sub-topic.
1482 1483 if '.' in topicname:
1483 1484 # TODO implement support for rendering sections, like
1484 1485 # `hg help` works.
1485 1486 topic, subtopic = topicname.split('.', 1)
1486 1487 if topic not in helpmod.subtopics:
1487 1488 raise ErrorResponse(HTTP_NOT_FOUND)
1488 1489 else:
1489 1490 topic = topicname
1490 1491 subtopic = None
1491 1492
1492 1493 try:
1493 1494 doc = helpmod.help_(u, commands, topic, subtopic=subtopic)
1494 1495 except error.Abort:
1495 1496 raise ErrorResponse(HTTP_NOT_FOUND)
1496 1497
1497 1498 web.res.setbodygen(tmpl(
1498 1499 'help',
1499 1500 topic=topicname,
1500 1501 doc=doc))
1501 1502
1502 1503 return web.res
1503 1504
1504 1505 # tell hggettext to extract docstrings from these functions:
1505 1506 i18nfunctions = commands.values()
General Comments 0
You need to be logged in to leave comments. Login now