#!/usr/bin/env python

import os.path, sys, re, shutil
from subprocess import Popen, PIPE
from twisted.python import usage
from foolscap.appserver import cli
from foolscap.api import fireEventually
from twisted.internet import reactor

SYNOPSIS = '''\
usage:
 git-foolscap [--flappserver=] create read-only|read-write COMMENT
 git-foolscap [--flappserver=] start
 git-foolscap [--flappserver=] stop
 git-foolscap [--flappserver=] list
 git-foolscap [--flappserver=] revoke FURL

    --flappserver   Use a pre-configured flappserver directory instead of
                    .git/foolscap
'''
SHORTHELP = '''\
Use this tool to publish a git repository via a Foolscap application server
(aka "flappserver"). Once configured, this creates an access string known as
a "FURL". You can then use this FURL as a git URL on any client which has the
"git-remote-pb" helper installed.

Run "git-foolscap --help" for more deatils (not "git foolscap --help", as
there is no man page).
'''

LONGHELP = '''\
Use this tool to publish a git repository via a Foolscap application server
(aka "flappserver"). Once configured, this creates an access string known as
a "FURL". You can then use this FURL as a git URL on any client which has the
"git-remote-pb" helper installed.

These FURLs provide cryptographically-secure access to a specific resource.
Unlike SSH keys, the holder of this FURL is limited to a single command (e.g.
git receive-pack). This is safer and easier to configure than putting
command/environment restrictions on an SSH key, and does not require running
a daemon as root.

To publish a git repo, run:

 git foolscap create read-only|read-write "for Bob"

That command will emit a FURL. Simply give this FURL to somebody via a secure
channel and have them run "git remote add NAME FURL". Remind them to install
Foolscap and the "git-remote-pb" program. The comment, which follows the
mode, is mandatory, and is recorded locally to help you remember who you gave
the FURL to.


This tool helps create a daemon that listens for inbound Foolscap
connections. The daemon must be running and reachable by your clients. A
working directory for the daemon is created in .git/foolscap . You must start
the daemon before clients can use it:

 git foolscap start

You probably want to arrange for the daemon to be started at system reboot as
well. On OS-X systems, use LaunchAgent. On unix systems (with Vixie cron),
the simplest technique is to add a "@reboot" crontab entry that looks like
this:

 @reboot cd PATH/TO/REPO && git foolscap start

The server this tool creates is known as a "flappserver", and Foolscap has a
number of tools to work with them. You may have already created a
flappserver, in which case you can tell git-foolscap to use that one instead
of creating a brand new one. To do that, use --flappserver like so:

 git foolscap --flappserver=~/.flappserver create read-only "for Alice"

The published FURL will either be limited to read-only operations, or it will
allow both read and write (i.e. clients can push into this repo). You must
choose one or the other when you create the FURL. Note that you can easily
create multiple FURLs for the same repo, some read-only, others read-write.

 git foolscap create read-only for-Alice
 git foolscap create read-write "for Bob"

You can revoke any FURL, to shut off access by whoever you gave that FURL to.
It is useful to create a new FURL for each client, so you can revoke them
separately.

 git foolscap revoke FURL

To list all the FURLs that are enabled, use "list":

 git foolscap list
'''

def probably_git_repo(repodir):
    return (os.path.exists(os.path.join(repodir, "objects"))
            and os.path.exists(os.path.join(repodir, "refs")))

def get_repodir():
    #repodir = os.environ["GIT_DIR"]
    d = Popen(["git", "rev-parse", "--git-dir"], stdout=PIPE).communicate()[0]
    repodir = os.path.abspath(os.path.expanduser(d.strip()))
    if not probably_git_repo(repodir):
        raise usage.UsageError("%s doesn't look like a .git directory" % repodir)
    return repodir

def get_reponame(repodir):
    # This should handle both .git/ directories (using the parent directory
    # name), and bare "foo.git" directories (using the part before ".git").
    base = os.path.basename(repodir)
    if base == ".git":
        name = os.path.basename(os.path.split(repodir)[0])
    else:
        assert base.endswith(".git")
        name = base[:-len(".git")]
    return name

class BaseOptions(usage.Options):
    def opt_h(self):
        return self.opt_help()

    def getSynopsis(self):
        # the default usage.Options.getSynopsis prepends the parent's
        # synopsis, which looks weird
        return self.synopsis

class CreateOptions(BaseOptions):
    synopsis = "git-foolscap [--flappserver=] create read-write|read-only COMMENT"
    def parseArgs(self, mode=None, comment=None):
        if mode not in ("read-write", "read-only"):
            raise usage.UsageError("mode must be 'read-write' or 'read-only'")
        self["mode"] = mode
        if not comment:
            raise usage.UsageError("comment is required")
        self["comment"] = comment
    longdesc='''Create a new repository-accessing FURL, using the server
    given by the --flappserver= option (which defaults to .git/foolscap),
    creating the server if necessary. If the mode is "read-write", the furl
    will have full pull and push authority. If the mode is "read-only", the
    furl will only be able to pull from this repo.

    The COMMENT (which is mandatory) is recorded along with the furl and
    displayed in "git-foolcap list", which may help you remember who has
    which furl so you can later do "git-foolscap revoke" on the right one.
    '''

class RevokeOptions(BaseOptions):
    synopsis = "git-foolscap [--flappserver=] revoke FURL"

    def parseArgs(self, FURL=None):
        if not FURL:
            raise usage.UsageError("FURL is required")
        self["FURL"] = FURL

    longdesc='''Revoke a FURL previously created with "git-foolscap create".
    Use "git-foolscap list" to get a list of revocable FURLs.'''

class ListOptions(BaseOptions):
    synopsis = "git-foolscap [--flappserver=] list"
    longdesc='''List all FURLs previously created, with their comments.'''

class StartOptions(BaseOptions):
    synopsis = "git-foolscap [--flappserver=] start"
    longdesc='''Start the flappserver. Must be done before clients can
    connect.

    You should probably arrange for this to be done at system reboot, perhaps
    with a crontab entry like "@reboot git-foolscap start GITREPO"'''

class StopOptions(BaseOptions):
    synopsis = "git-foolscap [--flappserver=] stop"
    longdesc='''Stop the flappserver.'''

class Options(usage.Options):
    synopsis = "git-foolscap [--flappserver=] (create (read-only|read-write) COMMENT)|revoke FURL|list|start|stop"
    optParameters = [
        ("flappserver", None, None, "where the flappserver lives"),
        ]

    subCommands = [("create", None, CreateOptions, "Publish a new FURL"),
                   ("revoke", None, RevokeOptions, "Revoke a previous FURL"),
                   ("list", None, ListOptions, "List all active FURLs"),
                   ("start", None, StartOptions, "Start the server"),
                   ("stop", None, StopOptions, "Stop the server"),
                   ]

    def opt_h(self):
        print SYNOPSIS+"\n"+SHORTHELP
        sys.exit(0)
    def opt_help(self):
        print SYNOPSIS+"\n"+LONGHELP
        sys.exit(0)

    def postOptions(self):
        self.repodir = get_repodir()
        if self["flappserver"]:
            self.serverdir = os.path.expanduser(self["flappserver"])
        else:
            self.serverdir = os.path.join(self.repodir, "foolscap")

def restart_server(serverdir):
    stop_options = cli.StopOptions()
    stop_options.stderr = sys.stderr
    stop_options.parseArgs(serverdir)
    stop_options["quiet"] = True
    cli.Stop().run(stop_options)
    start_options = cli.StartOptions()
    start_options.stderr = sys.stderr
    start_options.parseArgs(serverdir)
    return cli.Start().run(start_options) # this never returns

def stop_server(serverdir):
    stop_options = cli.StopOptions()
    stop_options.stderr = sys.stderr
    stop_options.parseArgs(serverdir)
    return cli.Stop().run(stop_options)

class CreateError(Exception):
    """Error creating flappserver"""

def maybe_create_server(server_exists, serverdir):
    d = fireEventually()
    def _create(ign):
        sys.stdout.write("Creating flappserver in %s\n" % serverdir)
        d1 = cli.run_flappserver(["flappserver",
                                  "create", "--quiet", serverdir],
                                 run_by_human=False)
        def _created((rc,out,err)):
            sys.stderr.write(err)
            if rc != 0:
                sys.stdout.write(out)
                raise CreateError()
        d1.addCallback(_created)
        return d1
    if not server_exists:
        # we need to create it. This needs a reactor turn
        d.addCallback(_create)
    def _check_umask(ign):
        if not os.path.exists(os.path.join(serverdir, "umask")):
            print >>sys.stderr, "flappserver doesn't have --umask set: consider setting it to 022, otherwise permissions on working files may be messed up"
    d.addCallback(_check_umask)
    return d

def run_reactor_and_exit(d):
    stash_rc = []
    def good(rc):
        stash_rc.append(rc)
        reactor.stop()
    def oops(f):
        print "Command failed:"
        print f
        stash_rc.append(-1)
        reactor.stop()
    d.addCallbacks(good, oops)
    d.addErrback(oops)
    reactor.run()
    sys.exit(stash_rc[0])

def do_create(so, server_exists):
    repodir = so.parent.repodir
    reponame = get_reponame(repodir)
    serverdir = so.parent.serverdir
    d = maybe_create_server(server_exists, serverdir)
    def _add(ign):
        read_write = (so["mode"] == "read-write")

        comment = "allow read "
        if read_write:
            comment += "(and write) "
        comment += "access to the Git repository at %s" % repodir
        if so["comment"]:
            comment += " (%s)" % so["comment"]

        # git-upload-pack handles "git fetch" and "git ls-remote"
        git_services = ["git-upload-pack"]
        # git-upload-archive handles "git archive --remote"
        if read_write:
            git_services.append("git-receive-pack")

        base_swissnum = cli.make_swissnum() + "/" + reponame

        # each git command gets a sub-FURL
        for git_service in git_services:
            swissnum = "%s-%s" % (base_swissnum, git_service)
            args = ["--accept-stdin", "/"]
            args.append(git_service)
            if git_service == "git-upload-pack":
                args.extend(["--strict", "--timeout=600"])
            args.append(repodir)
            furl,servicedir = cli.add_service(serverdir, "run-command",
                                              args, comment, swissnum)

        # use the last furl/swissnum pair to figure out the base FURL.
        # Note that this isn't a real FURL: you must append one of the
        # accepted git-command-name strings to hit a real object.
        assert furl.endswith(swissnum)
        chop = len(swissnum) - len(base_swissnum)
        furl = furl[:-chop]

        print "%s FURL added:" % so["mode"]
        print furl
    d.addCallback(_add)
    run_reactor_and_exit(d)

def do_revoke(so):
    serverdir = so.parent.serverdir
    furl = so["FURL"]
    swissnum = furl.split("/")[-1]
    for d in os.list(os.path.join(serverdir, "services")):
        if d.startswith(swissnum):
            found = True
            shutil.rmtree(os.path.join(serverdir, "services", d))
    if found:
        print "removed %s" % swissnum
        if os.path.exists(os.path.join(serverdir, "twistd.pid")):
            print "restarting server.."
            restart_server(serverdir) # never returns
    else:
        print "No such FURL found!"
        sys.exit(1)

def do_list(so):
    serverdir = so.parent.serverdir
    services = cli.list_services(serverdir)
    found = {}
    for s in services:
        if s.service_type != "run-command":
            pass
        if not re.search(r'allow read (\(and write\) )?access to the Git repository at',
                         s.comment):
            pass
        chop = len(s.swissnum) - s.swissnum.index("-")
        base_furl = s.furl[:-chop]
        found[base_furl] = s.comment # possibly None
    for furl in sorted(found.keys()):
        print furl
        comment = found[furl]
        if comment:
            print " "+comment
        print
    if not services:
        print "no git-foolscap FURLs configured"

def run():
    o = Options()
    try:
        o.parseOptions()
    except usage.UsageError, e:
        c = o
        while hasattr(c, 'subOptions'):
            c = c.subOptions
        print >>sys.stderr, str(c)
        print >>sys.stderr, "Error:", e
        sys.exit(1)

    # make sure the flappserver exists
    server_exists = os.path.exists(os.path.join(o.serverdir, "flappserver.tac"))

    command = o.subCommand
    if not command:
        print str(o)
        sys.exit(0)
    so = o.subOptions

    if command == "create":
        return do_create(so, server_exists)

    if not server_exists:
        raise usage.UsageError("serverdir doesn't exist")

    if command == "revoke":
        return do_revoke(so)
    elif command == "list":
        return do_list(so)
    elif command == "start": # also does restart
        restart_server(o.serverdir) # this never returns
    elif command == "stop":
        stop_server(o.serverdir) # this never returns
    else:
        # I think this should never be reached
        raise usage.UsageError("unknown subcommand '%s'" % command)


'''
You can create as many FURLs as you want. Each one can be revoked separately.
To revoke a FURL, use "flappserver list" to find the one you want, get its
"swissnum", delete the corresponding directory under
~/.flappserver/services/SWISSNUM , then use "flappserver restart
~/.flappserver" to restart the server.
'''

if __name__ == '__main__':
    run()
