/*
 * 
 * Copyright 2004 Sun Microsystems, Inc. All Rights Reserved.
 * 
 * The contents of this file are subject to the Sun Community Source
 * License v3.0/Jini Technology Specific Attachment v 1.0 (the
 * "License"). You may not use this file except in compliance with the
 * License. You may obtain a copy of the License at
 * http://wwws.sun.com/software/jini/licensing/SCSL3_JiniTSA1.html.
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See
 * the License for the specific language governing rights and limitations
 * under the License.
 * 
 * The Reference Code is Jini Technology Core Platform code, v2.0. The
 * Developer of the Reference Code is Sun Microsystems, Inc.
 * 
 * Contributor(s): Sun Microsystems, Inc.
 * 
 * The contents of this file comply with the Jini Technology Core
 * Platform Compatibility Kit, v2.0A.
 * 
 * Tester(s): Sun Microsystems, Inc.
 * 
 * Test Platform(s):
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Solaris(TM)SPARC/x86
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Linux (Intel x86)
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Windows (Intel Platform)
 * 
 */
package com.sun.jini.tool;

import com.sun.jini.logging.Levels;
import com.sun.jini.start.LifeCycle;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FilePermission;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.StringTokenizer;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * A simple HTTP server, for serving up JAR and class files.
 * <p>
 * The following items are discussed below:
 * <ul>
 * <li>{@linkplain #main Command line options}
 * <li><a href="#logging">Logging</a>
 * <li><a href="#running">Examples for running ClassServer</a>
 * </ul>
 * <p>
 * <a name="logging"><h3>Logging</h3></a>
 * <p>
 *
 * This implementation uses the {@link Logger} named
 * <code>com.sun.jini.tool.ClassServer</code> to log information at the
 * following logging levels:
 * <p>
 * <table border="1" cellpadding="5"
 *         summary="Describes logging performed by ClassServer at different
 *	   logging levels">
 * <caption halign="center" valign="top"><b><code>
 *    com.sun.jini.tool.ClassServer</code></b></caption>
 *
 * <tr> <th scope="col">Level</th> <th scope="col">Description</th> </tr>
 * <tr>
 *   <td>{@link Level#SEVERE SEVERE}</td>
 *   <td>failure to accept an incoming connection</td>
 * </tr>
 * <tr>
 *   <td>{@link Level#WARNING WARNING}</td>
 *   <td>failure to read the contents of a requested file,
 *       failure to find the message resource bundle, failure while
 *       executing the <code>-stop</code> option
 *   </td>
 * </tr>
 * <tr>
 *   <td>{@link Level#INFO INFO}</td>
 *   <td>server startup and termination</td>
 * </tr>
 * <tr>
 *   <td>{@link Level#CONFIG CONFIG}</td>
 *   <td>the JAR files being used for <code>-trees</code></td>
 * </tr>
 * <tr>
 *   <td>{@link Levels#HANDLED HANDLED}</td>
 *   <td>failure reading an HTTP request or writing a response</td>
 * </tr>
 * <tr>
 *   <td>{@link Level#FINE FINE}</td>
 *   <td>bad HTTP requests, HTTP requests for nonexistent files</td>
 * </tr>
 * <tr>
 *   <td>{@link Level#FINER FINER}</td>
 *   <td>good HTTP requests</td>
 * </tr>
 * </table>
 *
 * <p>
 * <a name="running"><h3>Examples for running ClassServer</h3></a>
 * <p>
 *
 * This server can be run directly from the 
 * {@linkplain #main command line}
 * or as a nonactivatable service under the 
 * {@linkplain com.sun.jini.start Service Starter}.
 * <p>
 * An example of running directly from the command line is:
 * <blockquote><pre>
 * % java -jar <var><b>install_dir</b></var>/lib/tools.jar
 *        -port 8081 -dir <var><b>install_dir</b></var>/lib -verbose
 * </pre></blockquote>
 * where <var><b>install_dir</b></var>
 * is the directory where the starter kit is installed.
 * This command places the class server on the (non-default) port 
 * 8081, which  serves out the files under the (non-default) directory  
 * <var><b>install_dir</b></var>/lib. The <code>-verbose</code> option
 * also causes download attempts to be logged.
 * <p>
 * An example of running under the Service Starter is:
 * <blockquote><pre>
 * % java -Djava.security.policy=<var><b>start_policy</b></var>
 *        -jar <var><b>install_dir</b></var>/lib/start.jar
 *        <a href="#config">httpd.config</a>
 * </pre></blockquote>
 * <p>
 * where <var><b>start_policy</b></var> is the name of a security
 * policy file (not provided), and <code>httpd.config</code> is the
 * following configuration file:
 * <a name="config"></a>
 * <blockquote><pre>
 * import com.sun.jini.start.NonActivatableServiceDescriptor;
 * import com.sun.jini.start.ServiceDescriptor;
 * 
 * com.sun.jini.start {
 * 
 *   serviceDescriptors = new ServiceDescriptor[]{
 *     new NonActivatableServiceDescriptor(
 *       "",
 *       "<var><b>httpd_policy</b></var>",
 *       "<var><b>install_dir</b></var>/lib/tools.jar",
 *       "com.sun.jini.tool.ClassServer",
 *       new String[]{"-port", "8081", "-dir", "<var><b>install_dir</b></var>/lib", "-verbose"})
 *     };
 * }
 * </pre></blockquote>
 * where <var><b>httpd_policy</b></var> is the name of a security
 * policy file (not provided).
 *
 * @author Sun Microsystems, Inc.
 *
 */
public class ClassServer extends Thread {
    /** Default HTTP port */
    private static int DEFAULT_PORT = 8080;
    /** Default directory to serve files from on non-Windows OS */
    private static String DEFAULT_DIR = "/vob/jive/lib";
    /** Default directory to serve files from on Windows */
    private static String DEFAULT_WIN_DIR = "J:";
    private static Logger logger =
			    Logger.getLogger("com.sun.jini.tool.ClassServer");

    /** Server socket to accept connections on */
    private ServerSocket server;
    /** Directory to serve from */
    private String dir;
    /** Map from String (JAR root) to JarFile[] (JAR class path) */
    private HashMap map;
    /** Verbosity flag */
    private boolean verbose;
    /** Read permission on dir and all subdirs */
    private FilePermission perm;
    /** Life cycle control */
    private LifeCycle lifeCycle;

    /**
     * Construct a server.  Use the start method to run it.
     *
     * @param port the port to use
     * @param dir the directory to serve files from
     * @param trees true if files within JAR files should be served up
     * @param verbose true if downloads should be logged
     * @throws IOException if the server socket cannot be created
     * @throws NullPointerException if <code>dir</code> is <code>null</code>
     */
    public ClassServer(int port, String dir, boolean trees, boolean verbose)
	throws IOException
    {
	init(port, dir, trees, verbose, null);
    }

    /**
     * Do the real work of the constructor.
     */
    private void init(int port,
		      String dir,
		      boolean trees,
		      boolean verbose,
		      LifeCycle lifeCycle)
	throws IOException
    {
	if (!dir.endsWith(File.separator))
	    dir = dir + File.separatorChar;
	this.dir = dir;
	this.verbose = verbose;
	this.lifeCycle = lifeCycle;
	server = new ServerSocket(port);
	perm = new FilePermission(dir + '-', "read");
	if (!trees)
	    return;
	map = new HashMap();
	File fdir = new File(dir);
	String[] files = fdir.list();
	if (files == null)
	    return;
	URL base = fdir.toURL();
	for (int i = 0; i < files.length; i++) {
	    String jar = files[i];
	    if (!jar.endsWith(".jar") && !jar.endsWith(".zip"))
		continue;
	    ArrayList jfs = new ArrayList(1);
	    addJar(jar, jfs, base);
	    map.put(jar.substring(0, jar.length() - 4),
		    jfs.toArray(new JarFile[jfs.size()]));
	}
    }

    /**
     * Construct a running server, accepting the same command line options
     * supported by {@link #main main}, except for the <code>-stop</code>
     * option.
     *
     * @param args command line options
     * @param lifeCycle life cycle control object, or <code>null</code>
     * @throws IOException if the server socket cannot be created
     * @throws IllegalArgumentException if a command line option is not
     * understood
     * @throws NullPointerException if <code>args</code> or any element
     * of <code>args</code> is <code>null</code>
     */
    public ClassServer(String[] args, LifeCycle lifeCycle) throws IOException {
	int port = DEFAULT_PORT;
	String dir = DEFAULT_DIR;
	if (File.separatorChar == '\\')
	    dir = DEFAULT_WIN_DIR;
	boolean trees = false;
	boolean verbose = false;
	for (int i = 0; i < args.length ; i++ ) {
	    String arg = args[i];
	    if (arg.equals("-port")) {
		i++;
		port = Integer.parseInt(args[i]);
	    } else if (arg.equals("-dir")) {
		i++;
		dir = args[i];
	    } else if (arg.equals("-verbose")) {
		verbose = true;
	    } else if (arg.equals("-trees")) {
		trees = true;
	    } else {
		throw new IllegalArgumentException(arg);
	    }
	}
	init(port, dir, trees, verbose, lifeCycle);
	start();
    }

    /** Add transitive Class-Path JARs to jfs. */
    private void addJar(String jar, ArrayList jfs, URL base)
	throws IOException
    {
	base = new URL(base, jar);
	jar = base.getFile().replace('/', File.separatorChar);
	for (int i = jfs.size(); --i >= 0; ) {
	    if (jar.equals(((JarFile) jfs.get(i)).getName()))
		return;
	}
	if (verbose)
	    print("classserver.jar", jar);
	logger.config(jar);
	JarFile jf = new JarFile(jar);
	jfs.add(jf);
	Manifest man = jf.getManifest();
	if (man == null)
	    return;
	Attributes attrs = man.getMainAttributes();
	if (attrs == null)
	    return;
	String val = attrs.getValue(Attributes.Name.CLASS_PATH);
	if (val == null)
	    return;
	for (StringTokenizer st = new StringTokenizer(val);
	     st.hasMoreTokens(); )
	{
	    addJar(st.nextToken(), jfs, base);
	}
    }

    /** Just keep looping, spawning a new thread for each incoming request. */
    public void run() {
	logger.log(Level.INFO, "ClassServer started [{0}, port {1}]",
		   new Object[]{dir, Integer.toString(getPort())});
	try {
	    while (true) {
		new Task(server.accept()).start();
	    }
	} catch (IOException e) {
	    synchronized (this) {
		if (verbose) {
		    e.printStackTrace();
		}
		if (!server.isClosed())
		    logger.log(Level.SEVERE, "accepting connection", e);
		terminate();
	    }
	}
    }

    /** Close the server socket, causing the thread to terminate. */
    public synchronized void terminate() {
	verbose = false;
	try {
	    server.close();
	} catch (IOException e) {
	}
	if (lifeCycle != null)
	    lifeCycle.unregister(this);
	logger.log(Level.INFO, "ClassServer terminated [port {0}]",
		   Integer.toString(getPort()));
    }

    /** Returns the port on which this server is listening. */
    public int getPort() {
	return server.getLocalPort();
    }

    /** Simple daemon task thread */
    private class Task extends Thread {
	/** Socket for the incoming request */
	private Socket sock;

	/** Simple constructor */
	public Task(Socket sock) {
	    this.sock = sock;
	    setDaemon(true);
	}

	/** Read up to CRLF, return false if EOF */
	private boolean readLine(InputStream in, StringBuffer buf)
	    throws IOException
	{
	    while (true) {
		int c = in.read();
		if (c < 0)
		    return buf.length() > 0;
		if (c == '\r') {
		    in.mark(1);
		    c = in.read();
		    if (c != '\n')
			in.reset();
		    return true;
		}
		if (c == '\n')
		    return true;
		buf.append((char) c);
	    }
	}

	/** Read the request and return the initial request line. */
	private String getRequest() throws IOException {
	    BufferedInputStream in =
		new BufferedInputStream(sock.getInputStream(), 256);
	    StringBuffer buf = new StringBuffer(80);
	    do {
		if (!readLine(in, buf))
		    return null;
	    } while (buf.length() == 0);
	    String req = buf.toString();
	    do {
		buf.setLength(0);
	    } while (readLine(in, buf) && buf.length() > 0);
	    return req;
	}

	/** Read specified number of bytes and always close the stream. */
	private byte[] getBytes(InputStream in, long length)
	    throws IOException
	{
	    DataInputStream din = new DataInputStream(in);
	    byte[] bytes = new byte[(int)length];
	    try {
		din.readFully(bytes);
	    } finally {
		din.close();
	    }
	    return bytes;
	}

	/** Parse % HEX HEX from s starting at i */
	private char decode(String s, int i) {
	    return (char) Integer.parseInt(s.substring(i + 1, i + 3), 16);
	}

	/** Canonicalize the path */
	private String canon(String path) {
	    try {
		if (path.regionMatches(true, 0, "http://", 0, 7)) {
		    int i = path.indexOf('/', 7);
		    if (i < 0)
			path = "/";
		    else
			path = path.substring(i);
		}
		for (int i = path.indexOf('%');
		     i >= 0;
		     i = path.indexOf('%', i + 1))
		{
		    char c = decode(path, i);
		    int n = 3;
		    if ((c & 0x80) != 0) {
			switch (c >> 4) {
			case 0xC:
			case 0xD:
			    n = 6;
			    c = (char)(((c & 0x1F) << 6) |
				       (decode(path, i + 3) & 0x3F));
			    break;
			case 0xE:
			    n = 9;
			    c = (char)(((c & 0x0f) << 12) |
				       ((decode(path, i + 3) & 0x3F) << 6) |
				       (decode(path, i + 6) & 0x3F));
			    break;
			default:
			    return null;
			}
		    }
		    path = path.substring(0, i) + c + path.substring(i + n);
		}
	    } catch (Exception e) {
		return null;
	    }
	    if (path.length() == 0 || path.charAt(0) != '/')
		return null;
	    return path.substring(1);
	}

	/** Return the bytes of the requested file, or null if not found. */
	private byte[] getBytes(String path) throws IOException {
	    if (map != null) {
		int i = path.indexOf('/');
		if (i > 0) {
		    JarFile[] jfs = (JarFile[])map.get(path.substring(0, i));
		    if (jfs != null) {
			String jpath = path.substring(i + 1);
			for (i = 0; i < jfs.length; i++) {
			    JarEntry je = jfs[i].getJarEntry(jpath);
			    if (je != null)
				return getBytes(jfs[i].getInputStream(je),
						je.getSize());
			}
		    }
		}
	    }
	    File f = new File(dir + path.replace('/', File.separatorChar));
	    if (perm.implies(new FilePermission(f.getPath(), "read"))) {
		try {
		    return getBytes(new FileInputStream(f), f.length());
		} catch (FileNotFoundException e) {
		}
	    }
	    return null;
	}

	/** Process the request */
	public void run() {
	    try {
		DataOutputStream out =
		    new DataOutputStream(sock.getOutputStream());
		String req;
		try {
		    req = getRequest();
		} catch (Exception e) {
		    if (verbose) {
			print("classserver.inputerror",
			      new String[]{sock.getInetAddress().getHostName(),
					   Integer.toString(sock.getPort())});
			e.printStackTrace();
		    }
		    logger.log(Levels.HANDLED, "reading request", e);
		    return;
		}
		if (req == null)
		    return;
		if (req.startsWith("SHUTDOWN *")) {
		    if (verbose)
			print("classserver.shutdown",
			      new String[]{sock.getInetAddress().getHostName(),
					   Integer.toString(sock.getPort())});
		    try {
			new ServerSocket(0, 1, sock.getInetAddress());
		    } catch (IOException e) {
			out.writeBytes("HTTP/1.0 403 Forbidden\r\n\r\n");
			out.flush();
			return;
		    }
		    terminate();
		    return;
		}
		String[] args = null;
		if (verbose || logger.isLoggable(Level.FINE))
		    args = new String[]{req,
					sock.getInetAddress().getHostName(),
					Integer.toString(sock.getPort())};
		boolean get = req.startsWith("GET ");
		if (!get && !req.startsWith("HEAD ")) {
		    if (verbose)
			print("classserver.badrequest", args);
		    logger.log(Level.FINE,
			       "bad request \"{0}\" from {1}:{2}", args);
		    out.writeBytes("HTTP/1.0 400 Bad Request\r\n\r\n");
		    out.flush();
		    return;
		}
		String path = req.substring(get ? 4 : 5);
		int i = path.indexOf(' ');
		if (i > 0)
		    path = path.substring(0, i);
		path = canon(path);
		if (path == null) {
		    if (verbose)
			print("classserver.badrequest", args);
		    logger.log(Level.FINE,
			       "bad request \"{0}\" from {1}:{2}", args);
		    out.writeBytes("HTTP/1.0 400 Bad Request\r\n\r\n");
		    out.flush();
		    return;
		}
		if (args != null)
		    args[0] = path;
		if (verbose) {
		    print(get ? "classserver.request" : "classserver.probe",
			  args);
		}
		logger.log(Level.FINER,
			   get ?
			   "{0} requested from {1}:{2}" :
			   "{0} probed from {1}:{2}",
			   args);
		byte[] bytes;
		try {
		    bytes = getBytes(path);
		} catch (Exception e) {
		    if (verbose)
			e.printStackTrace();
		    logger.log(Level.WARNING, "getting bytes", e);
		    out.writeBytes("HTTP/1.0 500 Internal Error\r\n\r\n");
		    out.flush();
		    return;
		}
		if (bytes == null) {
		    if (verbose)
			print("classserver.notfound", path);
		    logger.log(Level.FINE, "{0} not found", path);
		    out.writeBytes("HTTP/1.0 404 Not Found\r\n\r\n");
		    out.flush();
		    return;
		}
		out.writeBytes("HTTP/1.0 200 OK\r\n");
		out.writeBytes("Content-Length: " + bytes.length + "\r\n");
		out.writeBytes("Content-Type: application/java\r\n\r\n");
		if (get)
		    out.write(bytes);
		out.flush();
		if (get)
		    fileDownloaded(path, sock.getInetAddress());
	    } catch (Exception e) {
		if (verbose)
		    e.printStackTrace();
		logger.log(Levels.HANDLED, "writing response", e);
	    } finally {
		try {
		    sock.close();
		} catch (IOException e) {
		}
	    }
	}
    }

    private static ResourceBundle resources;
    private static boolean resinit = false;

    private static synchronized String getString(String key) {
	if (!resinit) {
	    try {
		resources = ResourceBundle.getBundle("com.sun.jini.tool.resources.classserver");
		resinit = true;
	    } catch (MissingResourceException e) {
		logger.log(Level.WARNING, "missing resource bundle {0}",
			   "com.sun.jini.tool.resources.classserver");
	    }
	}
	try {
	    return resources.getString(key);
	} catch (MissingResourceException e) {
	    return null;
	}
    }

    private static void print(String key, String val) {
	String fmt = getString(key);
	if (fmt == null)
	    fmt = "no text found: \"" + key + "\" {0}";
	System.out.println(MessageFormat.format(fmt, new String[]{val}));
    }

    private static void print(String key, String[] vals) {
	String fmt = getString(key);
	if (fmt == null)
	    fmt = "no text found: \"" + key + "\" {0} {1} {2}";
	System.out.println(MessageFormat.format(fmt, vals));
    }

    /**
     * This method provides a way for subclasses to be notified when a
     * file has been completely downloaded.
     * 
     * @param fp The path to the file that was downloaded.
     */
    protected void fileDownloaded(String fp, InetAddress addr) {
    }

    /**
     * Command line interface for creating an HTTP server.
     * The command line options are:
     * <pre>
     * [-port <var>port</var>] [-dir <var>dir</var>] [-trees] [-verbose]
     * </pre>
     * The default port is 8080; the default can be overridden with
     * the -port option.  The default directory on Windows is J:
     * and the default on other systems is /vob/jive/lib; the default
     * can be overridden with the -dir option.  By default, all files
     * under this directory (including all subdirectories) are served
     * up via HTTP.  If the pathname of a file is <var>path</var> relative
     * to the top-level directory, then the file can be downloaded using
     * the URL
     * <pre>
     * http://<var>host</var>:<var>port</var>/<var>path</var>
     * </pre>
     * No caching of directory contents or file contents is performed.
     * If the -verbose option is given, then all attempts to download files
     * are output. <p>
     * 
     * The -trees option can be used to serve up individual files stored
     * within JAR files in addition to the files that are served up by
     * default.  If the option is used, the server finds all JAR files
     * in the top-level directory (not in subdirectories).  For each
     * JAR file, if the name of the JAR file is <var>name</var>.jar, then any
     * individual file named <var>file</var> within that JAR file (or within
     * the JAR or zip files referenced transitively in the Class-Path manifest
     * attribute, can be downloaded using a URL of the form:
     * <pre>
     * http://<var>host</var>:<var>port</var>/<var>name</var>/<var>file</var>
     * </pre>
     * When this option is used, an open file descriptor and cached
     * information is held for each JAR file, for the life of the process.
     */
    public static void main(String[] args) {
	int port = DEFAULT_PORT;
	String dir = DEFAULT_DIR;
	if (File.separatorChar == '\\')
	    dir = DEFAULT_WIN_DIR;
	boolean trees = false;
	boolean verbose = false;
	boolean stop = false;
	for (int i = 0; i < args.length ; i++ ) {
	    String arg = args[i];
	    if (arg.equals("-port")) {
		i++;
		port = Integer.parseInt(args[i]);
	    } else if (arg.equals("-dir")) {
		i++;
		dir = args[i];
	    } else if (arg.equals("-verbose")) {
		verbose = true;
	    } else if (arg.equals("-trees")) {
		trees = true;
	    } else if (arg.equals("-stop")) {
		stop = true;
	    } else {
		print("classserver.usage", (String)null);
		return;
	    }
	}
	try {
	    if (stop) {
		Socket sock = new Socket(InetAddress.getLocalHost(), port);
		try {
		    DataOutputStream out =
			new DataOutputStream(sock.getOutputStream());
		    out.writeBytes("SHUTDOWN *\r\n\r\n");
		    out.flush();
		} finally {
		    try {
			sock.close();
		    } catch (IOException e) {
		    }
		}
	    } else {
		new ClassServer(port, dir, trees, verbose).start();
	    }
	} catch (IOException e) {
	    logger.log(Level.WARNING, "requesting shutdown", e);
	}
    }
}
