1#!/usr/local/bin/python3.83
2# -*-python-*-
3#
4# Copyright (C) 1999-2020 The ViewCVS Group. All Rights Reserved.
5#
6# By using this file, you agree to the terms and conditions set forth in
7# the LICENSE.html file which can be found at the top level of the ViewVC
8# distribution or at http://viewvc.org/license-1.html.
9#
10# For more information, visit http://viewvc.org/
11#
12# -----------------------------------------------------------------------
13#
14# This program originally written by Peter Funk <pf@artcom-gmbh.de>, with
15# contributions by Ka-Ping Yee.
16#
17# -----------------------------------------------------------------------
18
19#
20# INSTALL-TIME CONFIGURATION
21#
22# These values will be set during the installation process. During
23# development, they will remain None.
24#
25
26LIBRARY_DIR = None
27CONF_PATHNAME = None
28
29import sys
30import os
31import os.path
32import stat
33import string
34import socket
35import select
36import base64
37from urllib.parse import unquote as _unquote
38import http.server as _http_server
39
40if LIBRARY_DIR:
41  sys.path.insert(0, LIBRARY_DIR)
42else:
43  sys.path.insert(0, os.path.abspath(os.path.join(sys.argv[0], "../../lib")))
44
45import sapi
46import viewvc
47
48
49# The 'crypt' module is only available on Unix platforms.  We'll try
50# to use 'fcrypt' if it's available (for more information, see
51# http://carey.geek.nz/code/python-fcrypt/).
52has_crypt = False
53try:
54  import crypt
55  has_crypt = True
56  def _check_passwd(user_passwd, real_passwd):
57    return real_passwd == crypt.crypt(user_passwd, real_passwd[:2])
58except ImportError:
59  try:
60    import fcrypt
61    has_crypt = True
62    def _check_passwd(user_passwd, real_passwd):
63      return real_passwd == fcrypt.crypt(user_passwd, real_passwd[:2])
64  except ImportError:
65    def _check_passwd(user_passwd, real_passwd):
66      return False
67
68
69class Options:
70  port = 49152      # default TCP/IP port used for the server
71  repositories = {} # use default repositories specified in config
72  host = sys.platform == 'mac' and '127.0.0.1' or 'localhost'
73  script_alias = 'viewvc'
74  config_file = None
75  htpasswd_file = None
76
77
78class StandaloneServer(sapi.CgiServer):
79  """Custom sapi interface that uses a BaseHTTPRequestHandler HANDLER
80  to generate output."""
81
82  def __init__(self, handler):
83    sapi.Server.__init__(self)
84    self._headers = []
85    self._handler = handler
86    self._out_fp = handler.wfile
87    self._iis = False
88    global server
89    server = self
90
91  def start_response(self, content_type='text/html; charset=UTF-8', status=None):
92    sapi.Server.start_response(self, content_type, status)
93    if status is None:
94      statusCode = 200
95      statusText = 'OK'
96    else:
97      p = status.find(' ')
98      if p < 0:
99        statusCode = int(status)
100        statusText = ''
101      else:
102        statusCode = int(status[:p])
103        statusText = status[p+1:]
104    self._handler.send_response(statusCode, statusText)
105    self._handler.send_header("Content-type", content_type)
106    for (name, value) in self._headers:
107      self._handler.send_header(name, value)
108    self._handler.end_headers()
109
110  def write_text(self, s):
111    self.write(self, s.encode('utf-8', 'surrogateesape'))
112
113  def redirect(self, url):
114    self.add_header('Location', url)
115    self.start_response(status='301 Moved')
116    self.write_text(sapi.redirect_notice(url))
117
118  def write(self, s):
119    self._out_fp.write(s)
120
121  def flush(self):
122    self._out_fp.flush()
123
124  def file(self):
125    return self._out_fp
126
127
128class NotViewVCLocationException(Exception):
129  """The request location was not aimed at ViewVC."""
130  pass
131
132
133class AuthenticationException(Exception):
134  """Authentication requirements have not been met."""
135  pass
136
137
138class ViewVCHTTPRequestHandler(_http_server.BaseHTTPRequestHandler):
139  """Custom HTTP request handler for ViewVC."""
140
141  def do_GET(self):
142    """Serve a GET request."""
143    self.handle_request('GET')
144
145  def do_POST(self):
146    """Serve a POST request."""
147    self.handle_request('POST')
148
149  def handle_request(self, method):
150    """Handle a request of type METHOD."""
151    try:
152      self.run_viewvc()
153    except NotViewVCLocationException:
154      # If the request was aimed at the server root, but there's a
155      # non-empty script_alias, automatically redirect to the
156      # script_alias.  Otherwise, just return a 404 and shrug.
157      if (not self.path or self.path == "/") and options.script_alias:
158        new_url = self.server.url + options.script_alias + '/'
159        self.send_response(301, "Moved Permanently")
160        self.send_header("Content-type", "text/html")
161        self.send_header("Location", new_url)
162        self.end_headers()
163        self.wfile.write(("""<html>
164<head>
165<meta http-equiv="refresh" content="10; url=%s" />
166<title>Moved Temporarily</title>
167</head>
168<body>
169<h1>Redirecting to ViewVC</h1>
170<p>You will be automatically redirected to <a href="%s">ViewVC</a>.
171   If this doesn't work, please click on the link above.</p>
172</body>
173</html>
174""" % (new_url, new_url)).encode('utf-8'))
175      else:
176        self.send_error(404)
177    except IOError: # ignore IOError: [Errno 32] Broken pipe
178      pass
179    except AuthenticationException:
180      self.send_response(401, "Unauthorized")
181      self.send_header("WWW-Authenticate", 'Basic realm="ViewVC"')
182      self.send_header("Content-type", "text/html")
183      self.end_headers()
184      self.wfile.write(b"""<html>
185<head>
186<title>Authentication failed</title>
187</head>
188<body>
189<h1>Authentication failed</h1>
190<p>Authentication has failed.  Please retry with the correct username
191   and password.</p>
192</body>
193</html>""")
194
195  def is_viewvc(self):
196    """Check whether self.path is, or is a child of, the ScriptAlias"""
197    if not options.script_alias:
198      return 1
199    if self.path == '/' + options.script_alias:
200      return 1
201    alias_len = len(options.script_alias)
202    if self.path[:alias_len+2] == '/' + options.script_alias + '/':
203      return 1
204    if self.path[:alias_len+2] == '/' + options.script_alias + '?':
205      return 1
206    return 0
207
208  def validate_password(self, htpasswd_file, username, password):
209    """Compare USERNAME and PASSWORD against HTPASSWD_FILE."""
210    try:
211      lines = open(htpasswd_file, 'r').readlines()
212      for line in lines:
213        file_user, file_pass = line.rstrip().split(':', 1)
214        if username == file_user:
215          return _check_passwd(password, file_pass)
216    except:
217      pass
218    return False
219
220  def run_viewvc(self):
221    """Run ViewVC to field a single request."""
222
223    ### Much of this is adapter from Python's standard library
224    ### module CGIHTTPServer.
225
226    # Is this request even aimed at ViewVC?  If not, complain.
227    if not self.is_viewvc():
228      raise NotViewVCLocationException()
229
230    # If htpasswd authentication is enabled, try to authenticate the user.
231    self.username = None
232    if options.htpasswd_file:
233      authn = self.headers.get('authorization')
234      if not authn:
235        raise AuthenticationException()
236      try:
237        kind, data = authn.split(' ', 1)
238        if kind == 'Basic':
239          data = base64.b64decode(data)
240          username, password = data.split(':', 1)
241      except:
242        raise AuthenticationException()
243      if not self.validate_password(options.htpasswd_file, username, password):
244        raise AuthenticationException()
245      self.username = username
246
247    # Setup the environment in preparation of executing ViewVC's core code.
248    env = os.environ
249
250    scriptname = options.script_alias and '/' + options.script_alias or ''
251
252    viewvc_url = self.server.url[:-1] + scriptname
253    rest = self.path[len(scriptname):]
254    i = rest.rfind('?')
255    if i >= 0:
256      rest, query = rest[:i], rest[i+1:]
257    else:
258      query = ''
259
260    # Since we're going to modify the env in the parent, provide empty
261    # values to override previously set values
262    for k in env.keys():
263      if k[:5] == 'HTTP_':
264        del env[k]
265    for k in ('QUERY_STRING', 'REMOTE_HOST', 'CONTENT_LENGTH',
266              'HTTP_USER_AGENT', 'HTTP_COOKIE'):
267      if k in env:
268        env[k] = ""
269
270    # XXX Much of the following could be prepared ahead of time!
271    env['SERVER_SOFTWARE'] = self.version_string()
272    env['SERVER_NAME'] = self.server.server_name
273    env['GATEWAY_INTERFACE'] = 'CGI/1.1'
274    env['SERVER_PROTOCOL'] = self.protocol_version
275    env['SERVER_PORT'] = str(self.server.server_port)
276    env['REQUEST_METHOD'] = self.command
277    uqrest = _unquote(rest, 'utf-8', 'surrogateescape')
278    env['PATH_INFO'] = uqrest
279    env['SCRIPT_NAME'] = scriptname
280    if query:
281      env['QUERY_STRING'] = query
282    env['HTTP_HOST'] = self.server.address[0]
283    host = self.address_string()
284    if host != self.client_address[0]:
285      env['REMOTE_HOST'] = host
286    env['REMOTE_ADDR'] = self.client_address[0]
287    if self.username:
288      env['REMOTE_USER'] = self.username
289    env['CONTENT_TYPE'] = self.headers.get_content_type()
290    length = self.headers.get('content-length', None)
291    if length:
292      env['CONTENT_LENGTH'] = length
293    accept = []
294    for line in self.headers.getallmatchingheaders('accept'):
295      if line[:1] in string.whitespace:
296        accept.append(line.strip())
297      else:
298        accept = accept + line[7:].split(',')
299    env['HTTP_ACCEPT'] = ','.join(accept)
300    ua = self.headers.get('user-agent', None)
301    if ua:
302      env['HTTP_USER_AGENT'] = ua
303    modified = self.headers.get('if-modified-since', None)
304    if modified:
305      env['HTTP_IF_MODIFIED_SINCE'] = modified
306    etag = self.headers.get('if-none-match', None)
307    if etag:
308      env['HTTP_IF_NONE_MATCH'] = etag
309    # AUTH_TYPE
310    # REMOTE_IDENT
311    # XXX Other HTTP_* headers
312
313    try:
314      try:
315        viewvc.main(StandaloneServer(self), cfg)
316      finally:
317        if not self.wfile.closed:
318          self.wfile.flush()
319    except SystemExit as status:
320      self.log_error("ViewVC exit status %s", str(status))
321    else:
322      self.log_error("ViewVC exited ok")
323
324
325class ViewVCHTTPServer(_http_server.HTTPServer):
326  """Customized HTTP server for ViewVC."""
327
328  def __init__(self, host, port, callback):
329    self.address = (host, port)
330    self.url = 'http://%s:%d/' % (host, port)
331    self.callback = callback
332    _http_server.HTTPServer.__init__(self, self.address, self.handler)
333
334  def serve_until_quit(self):
335    self.quit = 0
336    while not self.quit:
337      rd, wr, ex = select.select([self.socket.fileno()], [], [], 1)
338      if rd:
339        self.handle_request()
340
341  def server_activate(self):
342    _http_server.HTTPServer.server_activate(self)
343    if self.callback:
344      self.callback(self)
345
346  def server_bind(self):
347    # set SO_REUSEADDR (if available on this platform)
348    if hasattr(socket, 'SOL_SOCKET') and hasattr(socket, 'SO_REUSEADDR'):
349      self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
350    _http_server.HTTPServer.server_bind(self)
351
352  def handle_error(self, request, client_address):
353    """Handle an error gracefully. use stderr instead of stdout
354    to avoid double fault.
355    """
356    sys.stderr.write('-'*40 + '\n')
357    sys.stderr.write('Exception happened during processing of request from '
358                     '%s\n' % str(client_address))
359    import traceback
360    traceback.print_exc()
361    sys.stderr.write('-'*40 + '\n')
362
363
364def serve(host, port, callback=None):
365  """Start an HTTP server for HOST on PORT.  Call CALLBACK function
366  when the server is ready to serve."""
367
368  ViewVCHTTPServer.handler = ViewVCHTTPRequestHandler
369
370  try:
371    # XXX Move this code out of this function.
372    # Early loading of configuration here.  Used to allow tinkering
373    # with some configuration settings:
374    handle_config(options.config_file)
375    if options.repositories:
376      cfg.general.default_root = "Development"
377      for repo_name in options.repositories.keys():
378        repo_path = os.path.normpath(options.repositories[repo_name])
379        if os.path.exists(os.path.join(repo_path, "CVSROOT", "config")):
380          cfg.general.cvs_roots[repo_name] = repo_path
381        elif os.path.exists(os.path.join(repo_path, "format")):
382          cfg.general.svn_roots[repo_name] = repo_path
383    elif "Development" in cfg.general.cvs_roots and \
384         not os.path.isdir(cfg.general.cvs_roots["Development"]):
385      sys.stderr.write("*** No repository found. Please use the -r option.\n")
386      sys.stderr.write("   Use --help for more info.\n")
387      raise KeyboardInterrupt # Hack!
388    os.close(0) # To avoid problems with shell job control
389
390    # always use default docroot location
391    cfg.options.docroot = None
392
393    ViewVCHTTPServer(host, port, callback).serve_until_quit()
394  except (KeyboardInterrupt, select.error):
395    pass
396  print('server stopped', flush=True)
397
398
399def handle_config(config_file):
400  global cfg
401  cfg = viewvc.load_config(config_file or CONF_PATHNAME)
402
403
404def usage():
405  clean_options = Options()
406  cmd = os.path.basename(sys.argv[0])
407  port = clean_options.port
408  host = clean_options.host
409  script_alias = clean_options.script_alias
410  sys.stderr.write("""Usage: %(cmd)s [OPTIONS]
411
412Run a simple, standalone HTTP server configured to serve up ViewVC requests.
413
414Options:
415
416  --config-file=FILE (-c)    Read configuration options from FILE.  If not
417                             specified, ViewVC will look for a configuration
418                             file in its installation tree, falling back to
419                             built-in default values.
420
421  --daemon (-d)              Background the server process.
422
423  --help                     Show this usage message and exit.
424
425  --host=HOSTNAME (-h)       Listen on HOSTNAME.  Required for access from a
426                             remote machine.  [default: %(host)s]
427
428  --htpasswd-file=FILE       Authenticate incoming requests, validating against
429                             against FILE, which is an Apache HTTP Server
430                             htpasswd file.  (CRYPT only; no DIGEST support.)
431
432  --port=PORT (-p)           Listen on PORT.  [default: %(port)d]
433
434  --repository=PATH (-r)     Serve the Subversion or CVS repository located
435                             at PATH.  This option may be used more than once.
436
437  --script-alias=PATH (-s)   Use PATH as the virtual script location (similar
438                             to Apache HTTP Server's ScriptAlias directive).
439                             For example, "--script-alias=repo/view" will serve
440                             ViewVC at "http://HOSTNAME:PORT/repo/view".
441                             [default: %(script_alias)s]
442""" % locals())
443  sys.exit(0)
444
445
446def badusage(errstr):
447  cmd = os.path.basename(sys.argv[0])
448  sys.stderr.write("ERROR: %s\n\n"
449                   "Try '%s --help' for detailed usage information.\n"
450                   % (errstr, cmd))
451  sys.exit(1)
452
453
454def main(argv):
455  """Command-line interface (looks at argv to decide what to do)."""
456  import getopt
457
458  short_opts = ''.join(['c:',
459                        'd',
460                        'h:',
461                        'p:',
462                        'r:',
463                        's:',
464                        ])
465  long_opts = ['daemon',
466               'config-file=',
467               'help',
468               'host=',
469               'htpasswd-file=',
470               'port=',
471               'repository=',
472               'script-alias=',
473               ]
474
475  opt_daemon = False
476  opt_host = None
477  opt_port = None
478  opt_htpasswd_file = None
479  opt_config_file = None
480  opt_script_alias = None
481  opt_repositories = []
482
483  # Parse command-line options.
484  try:
485    opts, args = getopt.getopt(argv[1:], short_opts, long_opts)
486    for opt, val in opts:
487      if opt in ['--help']:
488        usage()
489      elif opt in ['-r', '--repository']: # may be used more than once
490        opt_repositories.append(val)
491      elif opt in ['-d', '--daemon']:
492        opt_daemon = 1
493      elif opt in ['-p', '--port']:
494        opt_port = val
495      elif opt in ['-h', '--host']:
496        opt_host = val
497      elif opt in ['-s', '--script-alias']:
498        opt_script_alias = val
499      elif opt in ['-c', '--config-file']:
500        opt_config_file = val
501      elif opt in ['--htpasswd-file']:
502        opt_htpasswd_file = val
503  except getopt.error as err:
504    badusage(str(err))
505
506  # Validate options that need validating.
507  class BadUsage(Exception): pass
508  try:
509    if opt_port is not None:
510      try:
511        options.port = int(opt_port)
512      except ValueError:
513        raise BadUsage("Port '%s' is not a valid port number" % (opt_port))
514      if not options.port:
515        raise BadUsage("You must supply a valid port.")
516    if opt_htpasswd_file is not None:
517      if not os.path.isfile(opt_htpasswd_file):
518        raise BadUsage("'%s' does not appear to be a valid htpasswd file."
519                       % (opt_htpasswd_file))
520      if not has_crypt:
521        raise BadUsage("Unable to locate suitable `crypt' module for use "
522                       "with --htpasswd-file option.  If your Python "
523                       "distribution does not include this module (as is "
524                       "the case on many non-Unix platforms), consider "
525                       "installing the `fcrypt' module instead (see "
526                       "http://carey.geek.nz/code/python-fcrypt/).")
527      options.htpasswd_file = opt_htpasswd_file
528    if opt_config_file is not None:
529      if not os.path.isfile(opt_config_file):
530        raise BadUsage("'%s' does not appear to be a valid configuration file."
531                       % (opt_config_file))
532      options.config_file = opt_config_file
533    if opt_host is not None:
534      options.host = opt_host
535    if opt_script_alias is not None:
536      options.script_alias = '/'.join(filter(None, opt_script_alias.split('/')))
537    for repository in opt_repositories:
538      if 'Development' not in options.repositories:
539        rootname = 'Development'
540      else:
541        rootname = 'Repository%d' % (len(options.repositories.keys()) + 1)
542      options.repositories[rootname] = repository
543  except BadUsage as err:
544    badusage(str(err))
545
546  # Fork if we're in daemon mode.
547  if opt_daemon:
548    pid = os.fork()
549    if pid != 0:
550      sys.exit()
551
552  # Finaly, start the server.
553  def ready(server):
554    print(f'server ready at {server.url}{options.script_alias}', flush=True)
555  serve(options.host, options.port, ready)
556
557
558if __name__ == '__main__':
559  options = Options()
560  main(sys.argv)
561