#!/usr/bin/env python3 """ Cron-job that dumps files required in the filesystem to make pggit work. The job will also generate bare git repositories when new ones are added to the list, and remove those that are deleted. This means: ~/.ssh/authorized_keys """ import os import shutil import io import psycopg2 import configparser import urllib.parse from util.LockFile import LockFile def replace_file_from_string(fn, s): if os.path.isfile(fn): with open(fn) as f: old = f.read() if old == s: # No changes return False with open("{}.tmp".format(fn), "w") as f: f.write(s) os.chmod("{}.tmp".format(fn), 0o644) os.rename("{}.tmp".format(fn), fn) class AuthorizedKeysDumper(object): def __init__(self, db, conf): self.db = db self.conf = conf def dump(self): self.dumpkeys() self.dumprepos() def dumpkeys(self): # FIXME: use a trigger to indicate if *anything at all* has changed curs = self.db.cursor() curs.execute("SELECT username,sshkey FROM git_users INNER JOIN auth_user on auth_user.id=git_users.user_id ORDER BY username") f = open("%s/.ssh/authorized_keys.tmp" % self.conf.get("paths", "githome"), "w") for username, sshkey in curs: for key in sshkey.split("\n"): key = key.strip() if key: f.write("command=\"%s %s\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s\n" % (self.conf.get("paths", "pggit"), username, key)) f.close() os.chmod("%s/.ssh/authorized_keys.tmp" % self.conf.get("paths", "githome"), 0o600) os.rename("%s/.ssh/authorized_keys.tmp" % self.conf.get("paths", "githome"), "%s/.ssh/authorized_keys" % self.conf.get("paths", "githome")) def dumprepos(self): # FIXME: use a trigger to indicate if *anything at all* has changed allrepos = {} curs = self.db.cursor() curs.execute(""" SELECT name,anonymous,web,description,initialclone,tabwidth, COALESCE( (SELECT min(first_name || ' ' || last_name) FROM repository_permissions AS rp LEFT JOIN auth_user AS au ON au.id=rp.user_id WHERE rp.level=2 AND rp.repository=r.repoid),''), CASE WHEN EXISTS (SELECT * FROM remoterepositories WHERE remoterepositories.id=r.remoterepository_id) THEN 1 ELSE 0 END FROM repositories AS r WHERE approved ORDER BY name""") s = io.StringIO() webrepos = [] for name, anon, web, description, initialclone, tabwidth, owner, remoterepo in curs: allrepos[name] = 1 repopath = "%s/repos/%s.git" % (self.conf.get("paths", "githome"), name) # If this is a remote repository, don't try to create it if it's not there - # this is handled by the repository importer. if remoterepo and not os.path.isdir(repopath): continue # Check if this repository exists at all if not os.path.isdir(repopath): # Does not exist, let's initialize a new one # Does the parent directory exist? Needed for things like /user/foo/repo.git parentpath = os.path.normpath(os.path.join(repopath, os.pardir)) if not os.path.isdir(parentpath): # Parent does not exist, create it os.makedirs(parentpath) os.environ['GIT_DIR'] = repopath if initialclone: print("Initializing git into %s (cloned repo %s)" % (name, initialclone)) if initialclone.startswith('git://'): # Just use the raw URL, expect approver to have validated it oldrepo = initialclone else: # This is a local reference, so rewrite it based on our root oldrepo = "%s/repos/%s.git" % (self.conf.get("paths", "githome"), initialclone) os.system("git clone --bare %s %s/repos/%s.git" % ( # Old repo oldrepo, # New repo self.conf.get("paths", "githome"), name, )) else: print("Initializing new git repository %s" % name) os.system("git init --bare --shared") if os.path.isfile("{}/description".format(repopath)): os.remove("{}/description".format(repopath)) del os.environ['GIT_DIR'] # Check for publishing options here if web: cgitrc = io.StringIO() s.write("%s.git %s\n" % (urllib.parse.quote_plus(name), urllib.parse.quote_plus(owner))) replace_file_from_string( "%s/description" % repopath, description, ) # Check if we need to change the tab width (default is 8) repoconf = configparser.ConfigParser() repoconf.read("%s/config" % repopath) tabwidth_mod = False if repoconf.has_option('gitweb', 'tabwidth'): if tabwidth != int(repoconf.get('gitweb', 'tabwidth')): tabwidth_mod = True # Write to cgitrc, we check the contents for this one later # For now, we only support 4 space tabs (or the default 8) in cgit if int(repoconf.get('gitweb', 'tabwidth')) == 4: cgitrc.write('extra-head-content=') cgitrc.write("\n") else: # Not specified, so it's 8... if tabwidth != 8: tabwidth_mod = True if tabwidth_mod: print("Changing tab width for %s" % name) if not repoconf.has_section('gitweb'): repoconf.add_section('gitweb') repoconf.set('gitweb', 'tabwidth', str(tabwidth)) cf = open("%s/config" % repopath, "w") repoconf.write(cf) cf.close() # If one or more options are in the cgirtc file, create it if cgitrc.tell(): replace_file_from_string( "{}/cgitrc".format(repopath), cgitrc.getvalue(), ) else: if os.path.isfile("{}/cgitrc".format(repopath)): os.remove("{}/cgitrc".format(repopath)) else: # If repo should not be exposed on the web, remove the description file. We use this # as a trigger of whether to show it... if os.path.isfile("{}/description".format(repopath)): os.remove("{}/description".format(repopath)) anonfile = "%s/git-daemon-export-ok" % repopath if anon: if not os.path.isfile(anonfile): open(anonfile, "w").close() # When anonymous access is allowed, create an entry so # we can access it with http git. webrepos.append((name, repopath)) else: if os.path.isfile(anonfile): os.remove(anonfile) replace_file_from_string( self.conf.get("paths", "gitweblist"), s.getvalue(), ) if webrepos: changed = False if self.conf.has_option("paths", "lighttpdconf"): if replace_file_from_string( self.conf.get("paths", "lighttpdconf"), "alias.url += (\n{}\n)\n".format("\n".join([' "/git/{}.git/" => "{}/",'.format(name, path) for name, path in webrepos])), ): changed = True if self.conf.has_option("paths", "nginxconf"): if replace_file_from_string( self.conf.get("paths", "nginxconf"), """if ($args ~ "git-receive-pack") {{ return 403; }} if ($uri !~ "^/git/({})\.git") {{ return 404; }} """.format("|".join([name for name, path in webrepos])), ): changed = True if changed and self.conf.has_option("webserver", "reloadcommand"): os.system(self.conf.get("webserver", "reloadcommand")) # Now remove any repositories that have been deleted self._removerepos("%s/repos/" % self.conf.get("paths", "githome"), '/', allrepos) def _removerepos(self, rootpath, relativepath, allrepos): dl = os.listdir(rootpath) if not dl: # Nothing in there, perhaps we need to remove it? if relativepath != '/': print("Removing container directory %s" % rootpath) try: os.rmdir("%s" % rootpath) except Exception as e: print("FAIL: unable to remove container directory: %s" % e) return for d in dl: if d.startswith('.'): continue if not d.endswith('.git'): # If it doesn't end in '.git', that means it's a repository container # and not actually a repository. So we have to recurse. self._removerepos(os.path.join(rootpath, d), os.path.join(relativepath, d), allrepos) else: # Ends with '.git', meaning it's a repository. Let's figure out if it should # be here. d = d[:-4] if os.path.join(relativepath, d)[1:] not in allrepos: print("Removing repository %s" % os.path.join(relativepath, d)) try: shutil.rmtree("%s.git" % os.path.join(rootpath, d)) except Exception as e: print("FAIL: unable to remove directory: %s" % e) if __name__ == "__main__": c = configparser.ConfigParser() c.read("pggit.settings") lock = LockFile("%s/repos/.gitdump_interlock" % c.get("paths", "githome")) db = psycopg2.connect(c.get('database', 'db')) AuthorizedKeysDumper(db, c).dump()