#!python

import os.path, sys, re, json
from subprocess import Popen, PIPE
from collections import namedtuple
from twisted.python import usage
from twisted.internet.task import react
from twisted.internet.defer import inlineCallbacks
from foolscap.appserver import cli
from foolscap.api import fireEventually
import wormhole

SYNOPSIS = '''\
usage:
 git-foolscap init [--flappserver=] --port= --location=
 git-foolscap start
 git-foolscap stop
 git-foolscap add read-only|read-write COMMENT
 git-foolscap invite read-only|read-write COMMENT
 git-foolscap list
 git-foolscap revoke FURL
 git-foolscap accept clone|add-remote
'''
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 details (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.

You can also use "invite" and "accept" to deliver the FURL via a secure
protocol named "Magic-Wormhole". This prints a short string which you can read
to the recipient. This requires the sender to leave "git-foolscap invite"
running until the recipient has executed "git-foolscap accept".

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, first prepare the server. You must pick a TCP port to
listen on, and you need to know the hostname by which clients can reach your
computer.

 git foolscap init --port tcp:3116 --location tcp:example.com:3116

This creates 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

Now use the "add" command to grant read-write access to the repository.

 git foolscap create 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.

You can grant read-only access too:

 git foolscap create read-only "for Carol"

To use the invitation mechanism, use the "invite" command:

 git foolscap invite read-write "for Dave"

This produces a "wormhole code" instead of a FURL (the FURL is delivered
through the wormhole). Dave can then clone the repo by typing this code into:

 git foolscap accept clone

Or Dave can add the FURL to an existing repo with:

 git foolscap accept add-remote


Each 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 add read-only for-Alice
 git foolscap add 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. The "list" command will display an integer index for each FURL, and
you'll use that index to revoke them.

 git foolscap list
 git foolscap revoke 2
'''

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 InitOptions(BaseOptions):
    synopsis = "git-foolscap init [--flappserver=] --port= --location="
    optParameters = [
        ("flappserver", "s", None,
         "location of the server directory, defaults to GITDIR/foolscap"),
        ("port", "p", "tcp:3116",
         "(required) TCP port to listen on (server endpoint description)"),
        ("location", None, None,
         "(required) Tub location hints to use in generated FURLs, e.g. 'tcp:example.org:3116'"),
        ]

    longdesc='''\
Prepare a Git repository for access by Foolscap FURLs.

By default, this creates a foolscap "flappserver" inside the .git directory (at
.git/foolscap). This can be created elsewhere by using e.g.
--flappserver=~/.flappserver , which may be useful if you want to share the
server between multiple repositories. In this case, a symlink from
.git/foolscap to the --flappserver directory will be created. If the target
server already exists, it will not be modified, but the symlink will still be
created.

If the server is being created, it must be given a --port and --location. The
port is where the server listens: "tcp:3116" is the default. Location is how
clients are instructed to reach it, which typically consists of
type/hostname/port, like "tcp:example.com:3116". If the server already exists,
these arguments are ignored.
'''

class BaseAddOptions(BaseOptions):
    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

class AddOptions(BaseAddOptions):
    synopsis = "git-foolscap add read-write|read-only COMMENT"

    longdesc='''\
Add a new FURL for accessing this repository, in some particular mode.

Create a new repository-accessing FURL. 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 InviteOptions(BaseAddOptions):
    synopsis = "git-foolscap invite read-write|read-only COMMENT"

class AcceptOptions(BaseOptions):
    synopsis = "git-foolscap accept clone|add-remote [--remote=] [git args..]"
    optParameters = [
        ("remote", None, None, "Name of the new git remote"),
        ]
    def parseArgs(self, command, *gitargs):
        if command not in ("clone", "add-remote"):
            raise usage.UsageError("command must be 'clone' or 'add-remote'")
        self["command"] = command
        if self["remote"] is None:
            if command == "clone":
                self["remote"] = "origin"
            else:
                self["remote"] = "furl"


class RevokeOptions(BaseOptions):
    synopsis = "git-foolscap revoke WHICH"

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

    longdesc='''Revoke a FURL previously created with "git-foolscap create".
    Use "git-foolscap list" to get a list of revocable FURLs, and use the item
    number (#1, #2, etc) as WHICH'''

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

class StartOptions(BaseOptions):
    synopsis = "git-foolscap 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 stop"
    longdesc='''Stop the flappserver.'''

class Options(usage.Options):
    synopsis = "git-foolscap init|start|stop|add|list|revoke|invite|accept"

    subCommands = [("init", None, InitOptions, "Initialize a repo for publishing"),
                   ("invite", None, InviteOptions, "Extend invitation"),
                   ("add", None, AddOptions, "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"),

                   ("accept", None, AcceptOptions, "Accept invitation"),
                   ]

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

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"""


@inlineCallbacks
def do_init(reactor, so):
    if so["flappserver"] is not None:
        real_serverdir = os.path.abspath(os.path.expanduser(so["flappserver"]))
    else:
        real_serverdir = os.path.join(so.parent.repodir, "foolscap")

    real_server_exists = os.path.exists(os.path.join(real_serverdir,
                                                     "flappserver.tac"))
    if not real_server_exists:
        yield fireEventually()
        # we need to create it. This needs a reactor turn
        sys.stdout.write("Creating flappserver in %s\n" % real_serverdir)
        res = yield cli.run_flappserver(["flappserver", "create",
                                        "--port", so["port"],
                                        "--location", so["location"],
                                        "--quiet", real_serverdir],
                                        run_by_human=False)
        (rc, out, err) = res
        sys.stderr.write(err)
        if rc != 0:
            sys.stdout.write(out)
            raise CreateError()

    if not os.path.exists(os.path.join(real_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"

    if so["flappserver"] is not None:
        linkpath = so.parent.serverdir
        if os.path.isdir(linkpath):
            print "error, %s already exists (as a flappserver directory), I cannot make it into a symlink to %s" % (linkpath, real_serverdir)
        if not os.path.islink(linkpath):
            os.symlink(real_serverdir, linkpath)
            print "symlink from .git/foolscap to %s added" % (real_serverdir,)

    print "git-foolscap server initialized"

def add(so):
    repodir = so.parent.repodir
    reponame = get_reponame(repodir)
    serverdir = so.parent.serverdir
    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]
    return furl

def do_add(so):
    furl = add(so)
    print "%s FURL added:" % so["mode"]
    print furl

APPID = u"lothar.com/git-foolscap/v1"
MAILBOX_SERVER = u"ws://relay.magic-wormhole.io:4000/v1"

@inlineCallbacks
def do_invite(reactor, so):
    print "Generating new %s FURL '%s'" % (so["mode"], so["comment"])
    furl = add(so)
    print 'Please run "git foolscap accept clone" to make a new repo,'
    print 'or "git foolscap accept add-remote" to add a remote to an existing repo.'
    w = wormhole.create(APPID, MAILBOX_SERVER, reactor)
    w.allocate_code()
    code = yield w.get_code()
    print "The wormhole code is:"
    print
    print " ", code
    print
    print "Waiting for client to accept.."
    # TODO: make sure the server is running, else "clone" will fail as it tries
    # to immediately use the FURL
    w.send_message(furl)
    ack = yield w.get_message()
    print "Client says:", ack
    # TODO: if the client says failure, maybe revoke the FURL we just created,
    # to avoid leaving them lying around? Or let it remain, so the client can
    # manually add it? Let the inviter choose?
    yield w.close()

def do_revoke(so):
    serverdir = so.parent.serverdir
    services = list_services(serverdir)
    s = services[so["which"]]
    print "deleting furl #%d: %s" % (so["which"], s.furl)
    if s.comment:
        print " with comment: %s" % s.comment
    # TODO: get confirmation with input()

    # s.furl is pb://TUBID@HINTS/SWISS/REPO
    found = False
    fn = os.path.join(serverdir, "services.json")
    try:
        with open(fn, "r") as f:
            services_json = json.load(f)
    except EnvironmentError:
        print "unable to read %s: old flappserver?" % fn
        sys.exit(1)
    if services_json["version"] != 1:
        print "unrecognized flappserver version in %s" % fn
        sys.exit(1)
    for service_swiss, service_data in services_json["services"].items():
        # service_swiss is SWISS/REPO-FACET
        if s.furl.endswith(get_base_furl(service_swiss)):
            found = True
            # TODO: this should really live in a "flappserver remove"
            reldir = service_data["relative_basedir"]
            # the flappserver creates this working directory for the command,
            # but we never use it, so it ought to be empty
            os.rmdir(os.path.join(serverdir, reldir))
            del services_json["services"][service_swiss]
    if found:
        tmpfn = fn + ".tmp"
        with open(tmpfn, "w") as f:
            json.dump(services_json, f, indent=1)
        os.rename(tmpfn, fn)
        print "removed %s" % s.furl
        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)

Service = namedtuple("Service", ["furl", "comment"])

def get_base_furl(furl):
    # The server-side FURLs all end with "-git-upload-pack" or
    # "-git-receive-pack", which are read/write facets of the repo. The "FURL"
    # we send to the client omits this suffix, and the git-remote-pb client
    # adds one or the other depending upon what it's trying to do
    mo = re.search(r"(.*)-git-(upload|receive)-pack$", furl)
    assert mo, furl
    base_furl = mo.group(1)
    return base_furl

def list_services(serverdir):
    services = cli.list_services(serverdir)
    furls = {}
    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
        # merge the two read-write facet URLs
        furls[get_base_furl(s.furl)] = s.comment
    found = [Service(furl=furl, comment=comment)
             for (furl, comment) in furls.items()]
    found.sort(key=lambda s: s.furl)
    return found

def do_list(so):
    serverdir = so.parent.serverdir
    services = list_services(serverdir)
    for i, s in enumerate(services):
        print "#%d:" % i
        print " ", s.furl
        if s.comment:
            print "  ", s.comment
        print
    if not services:
        print "no git-foolscap FURLs configured"

@inlineCallbacks
def do_accept(reactor, so):
    # TODO: accept all git arguments (except URL) and pass them through to the
    # git command
    w = wormhole.create(APPID, MAILBOX_SERVER, reactor)
    wormhole.input_with_completion("Please enter the wormhole code: ",
                                   w.input_code(), reactor)
    furl = yield w.get_message()
    print "got FURL:", furl
    if so["command"] == "clone":
        cmd = ("git", "clone", "--origin", so["remote"], furl)
    else:
        print "adding new remote named %s" % so["remote"]
        # TODO: this can't work twice with the default --remote=furl, so maybe
        # ask git how many "furl*" remotes it already has and pick the next
        # higher one (furl2, furl3, etc)
        cmd = ("git", "remote", "add", so["remote"], furl)
    p = Popen(cmd)
    p.communicate()
    if p.returncode != 0:
        ack = "problems running git"
    else:
        ack = "ok"
    w.send_message(ack)
    yield w.close()

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)


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

    if command == "accept":
        return react(do_accept, (so,))

    # all subsequent commands must be run from an existing git repo

    o.repodir = get_repodir()
    o.serverdir = os.path.join(o.repodir, "foolscap") # maybe a symlink

    if command == "init":
        return react(do_init, (so,))

    # all subsequent commands must be run from an existing git repo that has
    # been prepared with "git-foolscap init"
    if not os.path.exists(os.path.join(o.serverdir, "flappserver.tac")):
        print >>sys.stderr, "serverdir missing, please use 'git-foolscap init' first"
        sys.exit(1)

    if command == "add":
        return do_add(so)
    elif command == "invite":
        return react(do_invite, (so,))
    elif 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()
