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