├── .gitignore ├── README.md ├── askpass.py ├── bootstrap.sh ├── chef ├── apache │ └── recipes │ │ ├── apache-jekit.conf │ │ ├── apache-ssl.conf │ │ └── default.rb ├── directories │ └── recipes │ │ └── default.rb ├── flask │ └── recipes │ │ └── default.rb ├── github-pages │ └── recipes │ │ └── default.rb ├── gunicorn │ └── recipes │ │ └── default.rb ├── requests │ └── recipes │ │ └── default.rb ├── roles.json └── solo.rb ├── cull-dirs.py ├── git.py ├── href.py ├── install.sh ├── jekyll.py ├── make-it-so.py ├── requirements.txt ├── scripts └── bookmarklet.js ├── templates ├── error-404.html ├── error-oauth.html ├── error-runtime.html ├── index.html ├── no-such-ref.html ├── no-such-repo.html └── page-layout.html └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | checkouts 3 | repos 4 | venv-* 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Git-Jekyll Preview 2 | ================== 3 | 4 | Preview your Github-hosted websites before making them live. Use it to check 5 | your plain static or [Jekyll](http://jekyllrb.com/)-generated websites before 6 | you make them live to [Github Pages](http://pages.github.com/) or to your own 7 | server. 8 | 9 | Try it live at [jekit.codeforamerica.org](http://jekit.codeforamerica.org). 10 | 11 | Status, Contact 12 | --------------- 13 | 14 | Git-Jekyll Preview is mostly a singleton-app, built only to be run at a single 15 | location. For the time being, it's not intended for general redeployment but 16 | improvements for [jekit.codeforamerica.org](http://jekit.codeforamerica.org) 17 | are welcomed. 18 | 19 | [Michal Migurski](https://github.com/migurski) is currently maintainer. 20 | 21 | Install 22 | ------- 23 | 24 | Git-Jekyll Preview is intended to be run on its own Ubuntu server, and will 25 | not currently work on a managed system like Heroku. Installation dependencies 26 | are managed by [Chef](https://wiki.opscode.com/display/chef/Home). It should 27 | be possible to install Chef and run all required recipes with the script 28 | [install.sh](install.sh). Note the world-writeable directories created in 29 | [chef/directories](chef/directories/recipes/default.rb). 30 | 31 | The application is a [Flask](http://flask.pocoo.org)-based Python server which 32 | shells out to [Git](https://www.kernel.org/pub/software/scm/git/docs/) for 33 | interaction with Github. [OAuth](http://developer.github.com/v3/oauth/) is 34 | used for authentication; put your client ID and secret in [git.py](git.py). 35 | 36 | To run for testing: 37 | 38 | python make-it-so.py 39 | 40 | To run in production, with [Gunicorn](http://gunicorn.org): 41 | 42 | gunicorn make-it-so:app 43 | -------------------------------------------------------------------------------- /askpass.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Short & sweet script for use with git clone and fetch credentials. 4 | # Requires GIT_USERNAME and GIT_PASSWORD environment variables, 5 | # intended to be called by Git via GIT_ASKPASS. 6 | # 7 | 8 | from sys import argv 9 | from os import environ 10 | 11 | if argv[1] == "Username for 'https://github.com': ": 12 | print environ['GIT_USERNAME'] 13 | exit() 14 | 15 | if argv[1] == "Password for 'https://%(GIT_USERNAME)s@github.com': " % environ: 16 | print environ['GIT_PASSWORD'] 17 | exit() 18 | 19 | exit(1) 20 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeforamerica/git-jekyll-preview/e6e4c3ee870254a3d1095a3b48c4554781abdf28/bootstrap.sh -------------------------------------------------------------------------------- /chef/apache/recipes/apache-jekit.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerAdmin webmaster@localhost 3 | 4 | DocumentRoot /var/www 5 | 6 | 7 | Options FollowSymLinks 8 | AllowOverride None 9 | 10 | 11 | 12 | Options Indexes FollowSymLinks MultiViews 13 | AllowOverride None 14 | Order allow,deny 15 | allow from all 16 | 17 | 18 | ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ 19 | 20 | 21 | AllowOverride None 22 | Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch 23 | Order allow,deny 24 | Allow from all 25 | 26 | 27 | ErrorLog ${APACHE_LOG_DIR}/error.log 28 | 29 | # Possible values include: debug, info, notice, warn, error, crit, 30 | # alert, emerg. 31 | LogLevel warn 32 | 33 | CustomLog ${APACHE_LOG_DIR}/access.log combined 34 | 35 | Alias /doc/ "/usr/share/doc/" 36 | 37 | 38 | Options Indexes MultiViews FollowSymLinks 39 | AllowOverride None 40 | Order deny,allow 41 | Deny from all 42 | Allow from 127.0.0.0/255.0.0.0 ::1/128 43 | 44 | 45 | 46 | ProxyPass http://127.0.0.1:8080/ 47 | ProxyPassReverse http://127.0.0.1:8080/ 48 | 49 | 50 | 51 | Allow from all 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /chef/apache/recipes/apache-ssl.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | ServerAdmin webmaster@localhost 4 | 5 | DocumentRoot /var/www 6 | 7 | 8 | Options FollowSymLinks 9 | AllowOverride None 10 | 11 | 12 | 13 | Options Indexes FollowSymLinks MultiViews 14 | AllowOverride None 15 | Order allow,deny 16 | allow from all 17 | 18 | 19 | ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ 20 | 21 | 22 | AllowOverride None 23 | Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch 24 | Order allow,deny 25 | Allow from all 26 | 27 | 28 | 29 | ProxyPass http://127.0.0.1:8080/ 30 | ProxyPassReverse http://127.0.0.1:8080/ 31 | 32 | 33 | 34 | Allow from all 35 | 36 | 37 | # Apache ProxyPassReverse doesn't seem to understand how to 38 | # rewrite headers to the correct URL schema, so we do it here. 39 | Header edit Location ^http://jekit.(.*)$ https://jekit.$1 40 | 41 | ErrorLog ${APACHE_LOG_DIR}/error.log 42 | 43 | # Possible values include: debug, info, notice, warn, error, crit, 44 | # alert, emerg. 45 | LogLevel warn 46 | 47 | CustomLog ${APACHE_LOG_DIR}/ssl_access.log combined 48 | 49 | # SSL Engine Switch: 50 | # Enable/Disable SSL for this virtual host. 51 | SSLEngine on 52 | 53 | # A self-signed (snakeoil) certificate can be created by installing 54 | # the ssl-cert package. See 55 | # /usr/share/doc/apache2.2-common/README.Debian.gz for more info. 56 | # If both key and certificate are stored in the same file, only the 57 | # SSLCertificateFile directive is needed. 58 | SSLCertificateFile /etc/ssl/certs/cfa-2014-ssl.crt 59 | SSLCertificateKeyFile /etc/ssl/private/cfa-2014-ssl.key 60 | 61 | 62 | SSLOptions +StdEnvVars 63 | 64 | 65 | SSLOptions +StdEnvVars 66 | 67 | 68 | # SSL Protocol Adjustments: 69 | # The safe and default but still SSL/TLS standard compliant shutdown 70 | # approach is that mod_ssl sends the close notify alert but doesn't wait for 71 | # the close notify alert from client. When you need a different shutdown 72 | # approach you can use one of the following variables: 73 | # o ssl-unclean-shutdown: 74 | # This forces an unclean shutdown when the connection is closed, i.e. no 75 | # SSL close notify alert is send or allowed to received. This violates 76 | # the SSL/TLS standard but is needed for some brain-dead browsers. Use 77 | # this when you receive I/O errors because of the standard approach where 78 | # mod_ssl sends the close notify alert. 79 | # o ssl-accurate-shutdown: 80 | # This forces an accurate shutdown when the connection is closed, i.e. a 81 | # SSL close notify alert is send and mod_ssl waits for the close notify 82 | # alert of the client. This is 100% SSL/TLS standard compliant, but in 83 | # practice often causes hanging connections with brain-dead browsers. Use 84 | # this only for browsers where you know that their SSL implementation 85 | # works correctly. 86 | # Notice: Most problems of broken clients are also related to the HTTP 87 | # keep-alive facility, so you usually additionally want to disable 88 | # keep-alive for those clients, too. Use variable "nokeepalive" for this. 89 | # Similarly, one has to force some clients to use HTTP/1.0 to workaround 90 | # their broken HTTP/1.1 implementation. Use variables "downgrade-1.0" and 91 | # "force-response-1.0" for this. 92 | BrowserMatch "MSIE [2-6]" \ 93 | nokeepalive ssl-unclean-shutdown \ 94 | downgrade-1.0 force-response-1.0 95 | # MSIE 7 and newer should be able to use keepalive 96 | BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /chef/apache/recipes/default.rb: -------------------------------------------------------------------------------- 1 | package 'apache2' 2 | 3 | link '/etc/apache2/mods-enabled/proxy.conf' do 4 | to '/etc/apache2/mods-available/proxy.conf' 5 | action :create 6 | end 7 | 8 | link '/etc/apache2/mods-enabled/proxy.load' do 9 | to '/etc/apache2/mods-available/proxy.load' 10 | action :create 11 | end 12 | 13 | link '/etc/apache2/mods-enabled/ssl.conf' do 14 | to '/etc/apache2/mods-available/ssl.conf' 15 | action :create 16 | end 17 | 18 | link '/etc/apache2/mods-enabled/ssl.load' do 19 | to '/etc/apache2/mods-available/ssl.load' 20 | action :create 21 | end 22 | 23 | link '/etc/apache2/mods-enabled/proxy_http.load' do 24 | to '/etc/apache2/mods-available/proxy_http.load' 25 | action :create 26 | end 27 | 28 | link '/etc/apache2/mods-enabled/headers.load' do 29 | to '/etc/apache2/mods-available/headers.load' 30 | action :create 31 | end 32 | 33 | file '/etc/apache2/sites-available/jekit' do 34 | owner 'root' 35 | group 'root' 36 | mode '0755' 37 | action :create 38 | content File.open(File.dirname(__FILE__) + "/apache-jekit.conf").read() 39 | end 40 | 41 | link '/etc/apache2/sites-enabled/000-default' do 42 | action :delete 43 | end 44 | 45 | link '/etc/apache2/sites-enabled/jekit' do 46 | to '/etc/apache2/sites-available/jekit' 47 | action :create 48 | end 49 | 50 | link '/etc/apache2/sites-enabled/default-ssl' do 51 | to '/etc/apache2/sites-available/default-ssl' 52 | action :delete 53 | end 54 | 55 | file '/etc/apache2/sites-available/jekit-ssl' do 56 | owner 'root' 57 | group 'root' 58 | mode '0755' 59 | action :create 60 | content File.open(File.dirname(__FILE__) + "/apache-ssl.conf").read() 61 | end 62 | 63 | file '/etc/ssl/certs/cfa-2014-ssl.crt' do 64 | owner 'root' 65 | group 'root' 66 | mode '0644' 67 | action :create 68 | end 69 | 70 | file '/etc/ssl/private/cfa-2014-ssl.key' do 71 | owner 'root' 72 | group 'root' 73 | mode '0644' 74 | action :create 75 | end 76 | 77 | link '/etc/apache2/sites-enabled/jekit-ssl' do 78 | to '/etc/apache2/sites-available/jekit-ssl' 79 | action :create 80 | end 81 | 82 | # 83 | # Make it go. 84 | # 85 | execute "apache2ctl restart" 86 | -------------------------------------------------------------------------------- /chef/directories/recipes/default.rb: -------------------------------------------------------------------------------- 1 | link '/opt/git-jekyll-preview' do 2 | to File.absolute_path(File.join(File.dirname(__FILE__), '..', '..', '..')) 3 | end 4 | 5 | directory '/opt/git-jekyll-preview/repos' do 6 | mode 0777 7 | action :create 8 | end 9 | 10 | directory '/opt/git-jekyll-preview/checkouts' do 11 | mode 0777 12 | action :create 13 | end 14 | 15 | directory '/var/log/jekit' do 16 | owner 'www-data' 17 | group 'www-data' 18 | mode '0775' 19 | action :create 20 | end 21 | 22 | file '/var/log/jekit/cull-dirs.log' do 23 | owner 'www-data' 24 | group 'www-data' 25 | mode '0644' 26 | action :create 27 | end 28 | 29 | file '/etc/cron.hourly/cull-dirs' do 30 | owner 'root' 31 | group 'root' 32 | mode '0755' 33 | action :create 34 | content <<-cron 35 | #!/bin/sh 36 | cd /opt/git-jekyll-preview 37 | sudo -u www-data python cull-dirs.py checkouts repos >> /var/log/jekit/cull-dirs.log 38 | cron 39 | end 40 | 41 | file '/etc/logrotate.d/cull-dirs' do 42 | owner 'root' 43 | group 'root' 44 | mode '0755' 45 | action :create 46 | content <<-conf 47 | /var/log/jekit/cull-dirs.log 48 | { 49 | copytruncate 50 | rotate 4 51 | weekly 52 | missingok 53 | notifempty 54 | compress 55 | delaycompress 56 | endscript 57 | } 58 | conf 59 | end 60 | -------------------------------------------------------------------------------- /chef/flask/recipes/default.rb: -------------------------------------------------------------------------------- 1 | package 'python-pip' 2 | 3 | bash 'install python-flask' do 4 | not_if 'python -c "import flask"' 5 | code 'pip install flask' 6 | end 7 | -------------------------------------------------------------------------------- /chef/github-pages/recipes/default.rb: -------------------------------------------------------------------------------- 1 | package 'ruby1.9.3' 2 | 3 | gem_package 'github-pages' do 4 | gem_binary '/usr/bin/gem1.9.3' 5 | options "--no-rdoc --no-ri" 6 | action :install 7 | end 8 | 9 | # Jekyll wants a JS runtime 10 | package 'nodejs' 11 | -------------------------------------------------------------------------------- /chef/gunicorn/recipes/default.rb: -------------------------------------------------------------------------------- 1 | package 'python-pip' 2 | 3 | bash 'install python-gunicorn' do 4 | not_if 'python -c "import gunicorn"' 5 | code 'pip install gunicorn' 6 | end 7 | 8 | directory '/var/log/jekit' do 9 | owner 'www-data' 10 | group 'www-data' 11 | mode '0775' 12 | action :create 13 | end 14 | 15 | file '/etc/init/jekit.conf' do 16 | owner 'root' 17 | group 'root' 18 | mode '0755' 19 | action :create 20 | content <<-conf 21 | description "jekit" 22 | 23 | start on (filesystem) 24 | stop on runlevel [016] 25 | 26 | respawn 27 | console log 28 | setuid www-data 29 | setgid www-data 30 | chdir /opt/git-jekyll-preview 31 | 32 | exec /usr/local/bin/gunicorn -b 0.0.0.0:8080 -w 4 -t 300 -e app-logfile=/var/log/jekit/app.log --access-logfile /var/log/jekit/access.log make-it-so:app 33 | conf 34 | end 35 | 36 | file '/etc/logrotate.d/jekit-access' do 37 | owner 'root' 38 | group 'root' 39 | mode '0755' 40 | action :create 41 | content <<-conf 42 | /var/log/jekit/access.log 43 | { 44 | copytruncate 45 | rotate 4 46 | weekly 47 | missingok 48 | notifempty 49 | compress 50 | delaycompress 51 | endscript 52 | } 53 | conf 54 | end 55 | 56 | file '/etc/logrotate.d/jekit-app' do 57 | owner 'root' 58 | group 'root' 59 | mode '0755' 60 | action :create 61 | content <<-conf 62 | /var/log/jekit/app.log 63 | { 64 | copytruncate 65 | rotate 4 66 | weekly 67 | missingok 68 | notifempty 69 | compress 70 | delaycompress 71 | endscript 72 | } 73 | conf 74 | end 75 | 76 | # 77 | # Make it go. 78 | # 79 | execute "stop jekit" do 80 | returns [0, 1] 81 | end 82 | 83 | execute "start jekit" 84 | -------------------------------------------------------------------------------- /chef/requests/recipes/default.rb: -------------------------------------------------------------------------------- 1 | package 'python-pip' 2 | 3 | bash 'install python-requests' do 4 | not_if 'python -c "from requests import get"' 5 | code 'pip install requests' 6 | end 7 | 8 | bash 'install python-requests-oauthlib' do 9 | not_if 'python -c "from requests_oauthlib import OAuth2Session"' 10 | code 'pip install requests-oauthlib' 11 | end 12 | -------------------------------------------------------------------------------- /chef/roles.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ubuntu 12.04", 3 | "ruby": "2.0.0-p353", 4 | "run_list": 5 | [ 6 | "github-pages", 7 | "flask", "requests", 8 | "directories", 9 | "apache", "gunicorn" 10 | ], 11 | "user": "ubuntu" 12 | } 13 | -------------------------------------------------------------------------------- /chef/solo.rb: -------------------------------------------------------------------------------- 1 | # 2 | # http://docs.opscode.com/config_rb_solo.html 3 | # 4 | file_cache_path "/tmp/chef-solo" 5 | cookbook_path File.dirname(__FILE__) 6 | log_level :info 7 | log_location "/var/log/cheflog" 8 | ssl_verify_mode :verify_none 9 | -------------------------------------------------------------------------------- /cull-dirs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import division 3 | from sys import argv 4 | from os.path import join, isdir 5 | from os import statvfs, listdir, stat, remove 6 | from shutil import rmtree 7 | 8 | if __name__ == '__main__': 9 | 10 | checkouts, repos = argv[1:] 11 | 12 | # Look at checkouts and repos in turn. 13 | for dirname in (checkouts, repos): 14 | 15 | # Calculate free space 16 | info = statvfs(dirname) 17 | free = info.f_bavail / info.f_blocks 18 | 19 | # Skip if free space is over 20% 20 | if free > .2: 21 | continue 22 | 23 | # Order directory contents by age, oldest-first 24 | paths = [join(dirname, name) for name in listdir(dirname)] 25 | times = [stat(path).st_mtime for path in paths] 26 | infos = zip(times, paths) 27 | infos.sort() 28 | 29 | if dirname == checkouts: 30 | # Remove twenty-five checkouts at a time 31 | removals = infos[:25] 32 | 33 | elif dirname == repos: 34 | # Remove one repo at a time 35 | removals = infos[:1] 36 | 37 | # Delete things 38 | for (time, path) in removals: 39 | print 'Removing', path 40 | 41 | if isdir(path): 42 | rmtree(path) 43 | else: 44 | remove(path) 45 | -------------------------------------------------------------------------------- /git.py: -------------------------------------------------------------------------------- 1 | from os.path import join, exists, dirname 2 | from os import getcwd, mkdir, environ 3 | from logging import getLogger 4 | 5 | from util import locked_file, is_fresh, touch, run_cmd 6 | from requests_oauthlib import OAuth2Session 7 | from requests import get 8 | 9 | github_client_id = r'e62e0d541bb6d0125b62' 10 | github_client_secret = r'1f488407e92a59beb897814e9240b5a06a2020e3' 11 | 12 | jlogger = getLogger('jekit') 13 | 14 | class PrivateRepoException (Exception): pass 15 | 16 | class MissingRepoException (Exception): pass 17 | 18 | class MissingRefException (Exception): pass 19 | 20 | def prepare_git_checkout(account, repo, ref, token): 21 | ''' 22 | ''' 23 | repo_href = 'https://github.com/%s/%s.git' % (account, repo) 24 | repo_path = join(getcwd(), 'repos/%s-%s' % (account, repo)) 25 | repo_refs = 'https://api.github.com/repos/%s/%s/branches' % (account, repo) 26 | repo_sha = 'https://api.github.com/repos/%s/%s/commits/%s' % (account, repo, ref) 27 | checkout_path = join(getcwd(), 'checkouts/%s-%s-%s' % (account, repo, ref)) 28 | checkout_lock = checkout_path + '.git-lock' 29 | 30 | if exists(checkout_path) and is_fresh(checkout_path): 31 | return checkout_path 32 | 33 | ref_check = OAuth2Session(github_client_id, token=token).get(repo_refs) 34 | 35 | if ref_check.status_code == 401: 36 | # Github wants authentication. 37 | raise PrivateRepoException() 38 | 39 | elif ref_check.status_code == 404: 40 | # This repository might not exist at all? 41 | raise MissingRepoException() 42 | 43 | branches = dict([(b['name'], b['commit']['sha']) for b in ref_check.json()]) 44 | ref_sha = branches.get(ref, None) 45 | 46 | if ref_sha is None: 47 | # The ref is not a branch, but it may be a sha. 48 | sha_check = OAuth2Session(github_client_id, token=token).get(repo_sha) 49 | 50 | if sha_check.status_code == 200: 51 | # The ref must be a sha hash. 52 | ref_sha = sha_check.json()['sha'] 53 | else: 54 | # The repository exists, but the branch does not? 55 | raise MissingRefException() 56 | 57 | if token: 58 | jlogger.debug('Adding Github credentials to environment') 59 | environ.update(dict(GIT_ASKPASS=join(dirname(__file__), 'askpass.py'))) 60 | environ.update(dict(GIT_USERNAME=token['access_token'], GIT_PASSWORD='')) 61 | 62 | else: 63 | jlogger.debug('Clearing Github credentials from environment') 64 | environ.update(dict(GIT_ASKPASS='', GIT_USERNAME='', GIT_PASSWORD='')) 65 | 66 | with locked_file(checkout_lock): 67 | if not exists(repo_path): 68 | git_clone(repo_href, repo_path) 69 | else: 70 | git_fetch(repo_path, ref, ref_sha) 71 | 72 | git_checkout(repo_path, checkout_path, ref) 73 | 74 | # Make sure these are gone before we return. 75 | environ.update(dict(GIT_ASKPASS='', GIT_USERNAME='', GIT_PASSWORD='')) 76 | 77 | return checkout_path 78 | 79 | def git_clone(href, path): 80 | ''' Clone a git repository from its remote address to a local path. 81 | ''' 82 | jlogger.info('Cloning to ' + path) 83 | run_cmd(('git', 'clone', '--mirror', href, path)) 84 | 85 | def get_ref_sha(repo_path, ref): 86 | ''' Get the current SHA for a ref in the given repo path. 87 | ''' 88 | return run_cmd(('git', 'show', '--pretty=%H', '--summary', ref), repo_path).strip() 89 | 90 | def git_fetch(repo_path, ref, sha): 91 | ''' Run `git fetch` inside a local git repository. 92 | ''' 93 | jlogger.info('Fetching in ' + repo_path) 94 | 95 | try: 96 | found_sha = get_ref_sha(repo_path, ref) 97 | except RuntimeError: 98 | # 99 | # Account for a missing ref by performing a complete fetch. 100 | # 101 | jlogger.debug('Complete fetch in '+repo_path) 102 | run_cmd(('git', 'fetch'), repo_path) 103 | found_sha = get_ref_sha(repo_path, ref) 104 | 105 | if sha == found_sha: 106 | jlogger.debug('Skipping fetch in '+repo_path) 107 | 108 | else: 109 | run_cmd(('git', 'fetch'), repo_path) 110 | 111 | touch(repo_path) 112 | 113 | def git_checkout(repo_path, checkout_path, ref): 114 | ''' Check out a git repository to a given reference and path. 115 | 116 | This function is assumed to be run in a lock. 117 | ''' 118 | jlogger.info('Checking out to ' + checkout_path) 119 | 120 | if not exists(checkout_path): 121 | mkdir(checkout_path) 122 | 123 | hash_file = checkout_path + '.commit-hash' 124 | commit_hash = get_ref_sha(repo_path, ref) 125 | 126 | do_checkout = True 127 | 128 | if exists(hash_file): 129 | previous_hash = open(hash_file).read().strip() 130 | 131 | if previous_hash == commit_hash: 132 | jlogger.debug('Skipping checkout to '+checkout_path) 133 | do_checkout = False 134 | 135 | if do_checkout: 136 | run_cmd(('git', '--work-tree='+checkout_path, 'checkout', ref, '--', '.'), repo_path) 137 | 138 | touch(checkout_path) 139 | 140 | with open(hash_file, 'w') as file: 141 | print >> file, commit_hash 142 | -------------------------------------------------------------------------------- /href.py: -------------------------------------------------------------------------------- 1 | from urlparse import urlparse 2 | from re import match 3 | 4 | def get_redirect(req_path, ref_url): 5 | ''' 6 | >>> get_redirect('/style.css', 'http://preview.local/foo/bar/baz/') 7 | '/foo/bar/baz/style.css' 8 | 9 | >>> get_redirect('/style.css', 'http://preview.local/foo/bar/baz/quux.html') 10 | '/foo/bar/baz/style.css' 11 | 12 | >>> get_redirect('/quux/style.css', 'http://preview.local/foo/bar/baz/') 13 | '/foo/bar/baz/quux/style.css' 14 | ''' 15 | _, ref_host, ref_path, _, _, _ = urlparse(ref_url) 16 | ref_git_preamble_match = match(r'((/[^/]+){3})', ref_path) 17 | 18 | return ref_git_preamble_match.group(1) + req_path 19 | 20 | def needs_redirect(req_host, req_path, ref_url): 21 | ''' 22 | Don't redirect when the request and referer hosts don't match: 23 | >>> needs_redirect('preview.local', '/style.css', 'http://example.com/foo/bar/baz/') 24 | False 25 | 26 | Don't redirect when the referer doesn't appear to include a git path. 27 | >>> needs_redirect('preview.local', '/style.css', 'http://preview.local/about/') 28 | False 29 | 30 | Don't redirect when the request path already includes the git preamble. 31 | >>> needs_redirect('preview.local', '/foo/bar/baz/style.css', 'http://preview.local/foo/bar/baz/') 32 | False 33 | 34 | >>> needs_redirect('preview.local', '/', 'http://preview.local/foo/bar/baz/') 35 | True 36 | 37 | >>> needs_redirect('preview.local', '/style.css', 'http://preview.local/foo/bar/baz/') 38 | True 39 | 40 | >>> needs_redirect('preview.local', '/fee/fi/fo/fum/style.css', 'http://preview.local/foo/bar/baz/') 41 | True 42 | ''' 43 | _, ref_host, ref_path, _, _, _ = urlparse(ref_url) 44 | 45 | # 46 | # Don't redirect when the request and referer hosts don't match. 47 | # 48 | if req_host != ref_host: 49 | return False 50 | 51 | ref_git_preamble_match = match(r'(/([^/]+/){3})', ref_path) 52 | 53 | # 54 | # Don't redirect when the referer doesn't appear to include a git path. 55 | # 56 | if not ref_git_preamble_match: 57 | return False 58 | 59 | # 60 | # Don't redirect when the request path already includes the git preamble. 61 | # 62 | if req_path.startswith(ref_git_preamble_match.group(1)): 63 | return False 64 | 65 | return True 66 | 67 | if __name__ == '__main__': 68 | import doctest 69 | doctest.testmod() 70 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | # 3 | # Install the chef ruby gem if chef-solo is not in the path. 4 | # This script is safe to run multiple times. 5 | # 6 | if [ ! `which chef-solo` ]; then 7 | apt-get install -y ruby1.9.3 build-essential 8 | gem1.9.3 install chef ohai --no-rdoc --no-ri 9 | fi 10 | 11 | BASE=`dirname $0` 12 | PREFIX="${BASE:0:1}" 13 | 14 | if [ $PREFIX != '/' ]; then 15 | BASE="`pwd`/${BASE}" 16 | fi 17 | 18 | chef-solo -c $BASE/chef/solo.rb \ 19 | -j $BASE/chef/roles.json 20 | -------------------------------------------------------------------------------- /jekyll.py: -------------------------------------------------------------------------------- 1 | from os.path import join, exists 2 | from logging import getLogger 3 | from shutil import copyfile 4 | 5 | from util import run_cmd, touch, is_fresh, locked_file 6 | 7 | jlogger = getLogger('jekit') 8 | 9 | def jekyll_build(checkout_path): 10 | ''' 11 | ''' 12 | checkout_lock = checkout_path + '.jekyll-lock' 13 | jekyll_path = join(checkout_path, '_site') 14 | built_hash_file = checkout_path + '.built-hash' 15 | hash_file = checkout_path + '.commit-hash' 16 | 17 | if exists(jekyll_path) and is_fresh(jekyll_path): 18 | return jekyll_path 19 | 20 | with locked_file(checkout_lock): 21 | do_build = True 22 | 23 | if exists(built_hash_file): 24 | built_hash = open(built_hash_file).read().strip() 25 | commit_hash = open(hash_file).read().strip() 26 | 27 | if built_hash == commit_hash: 28 | jlogger.debug('Skipping build to ' + jekyll_path) 29 | do_build = False 30 | 31 | if do_build: 32 | jlogger.info('Building jekyll ' + jekyll_path) 33 | run_cmd(('jekyll', 'build'), checkout_path) 34 | 35 | if exists(hash_file): 36 | copyfile(hash_file, built_hash_file) 37 | 38 | touch(jekyll_path) 39 | 40 | return jekyll_path 41 | -------------------------------------------------------------------------------- /make-it-so.py: -------------------------------------------------------------------------------- 1 | from logging import DEBUG, INFO, getLogger, FileHandler, StreamHandler, Formatter 2 | from os.path import join, isdir, isfile 3 | from traceback import format_exc 4 | from urllib import urlencode 5 | from os import environ 6 | from time import time 7 | 8 | from flask import Flask, redirect, request, make_response, render_template, session 9 | 10 | from requests import post 11 | from requests_oauthlib import OAuth2Session 12 | from git import prepare_git_checkout, PrivateRepoException 13 | from git import MissingRepoException, MissingRefException 14 | from href import needs_redirect, get_redirect 15 | from util import get_directory_response 16 | from util import get_file_response 17 | from util import errors_logged 18 | from jekyll import jekyll_build 19 | 20 | from git import github_client_id, github_client_secret 21 | flask_secret_key = 'poop' 22 | 23 | app = Flask(__name__) 24 | app.secret_key = flask_secret_key 25 | 26 | @app.before_first_request 27 | def adjust_log_level(): 28 | getLogger('jekit').setLevel(DEBUG if app.debug else INFO) 29 | 30 | def should_redirect(): 31 | ''' Return True if the current flask.request should redirect. 32 | ''' 33 | if request.args.get('go') == u'\U0001f44c': 34 | return False 35 | 36 | referer_url = request.headers.get('Referer') 37 | 38 | if not referer_url: 39 | return False 40 | 41 | return needs_redirect(request.host, request.path, referer_url) 42 | 43 | def make_redirect(): 44 | ''' Return a flask.redirect for the current flask.request. 45 | ''' 46 | referer_url = request.headers.get('Referer') 47 | 48 | other = redirect(get_redirect(request.path, referer_url), 302) 49 | other.headers['Cache-Control'] = 'no-store private' 50 | other.headers['Vary'] = 'Referer' 51 | 52 | return other 53 | 54 | def get_auth(): 55 | ''' Get (username, password) tuple from flask.request, or None. 56 | ''' 57 | auth = request.authorization 58 | 59 | return (auth.username, auth.password) if auth else None 60 | 61 | def get_token(): 62 | ''' Get OAuth token from flask.session, or a fake one guaranteed to fail. 63 | ''' 64 | token = dict(token_type='bearer', access_token='') 65 | token.update(session.get('token', {})) 66 | 67 | return token 68 | 69 | def make_401_response(): 70 | ''' Create an HTTP 401 Not Authorized response to trigger Github OAuth. 71 | 72 | Start by redirecting the user to Github OAuth authorization page: 73 | http://developer.github.com/v3/oauth/#redirect-users-to-request-github-access 74 | ''' 75 | state_id = 'foobar' # fake. 76 | states = session.get('states', {}) 77 | states[state_id] = dict(redirect=request.url, created=time()) 78 | session['states'] = states 79 | 80 | data = dict(scope='user,repo', client_id=github_client_id, state=state_id) 81 | 82 | auth = redirect('https://github.com/login/oauth/authorize?' + urlencode(data), 302) 83 | auth.headers['Cache-Control'] = 'no-store private' 84 | auth.headers['Vary'] = 'Referer' 85 | 86 | return auth 87 | 88 | def make_404_response(template, vars): 89 | ''' 90 | ''' 91 | return make_response(render_template(template, **vars), 404) 92 | 93 | def make_500_response(error, traceback): 94 | ''' 95 | ''' 96 | try: 97 | message = unicode(error) 98 | except UnicodeDecodeError: 99 | message = str(error).decode('latin-1') 100 | 101 | vars = dict(error=message, traceback=traceback) 102 | 103 | return make_response(render_template('error-runtime.html', **vars), 500) 104 | 105 | @app.route('/') 106 | @errors_logged 107 | def hello_world(): 108 | if should_redirect(): 109 | return make_redirect() 110 | 111 | id = session.get('id', None) 112 | 113 | script = ''' 114 | javascript:( 115 | function () 116 | { 117 | document.getElementsByTagName('head')[0].appendChild(document.createElement('script')).src='http://host:port/bookmarklet.js'; 118 | }() 119 | ); 120 | ''' 121 | 122 | script = script.replace('http', request.scheme) 123 | script = script.replace('host:port', request.host) 124 | script = script.replace(' ', '').replace('\n', '') 125 | 126 | return render_template('index.html', id=id, script=script, request=request) 127 | 128 | @app.route('/.well-known/status') 129 | @errors_logged 130 | def wellknown_status(): 131 | if should_redirect(): 132 | return make_redirect() 133 | 134 | status = ''' 135 | { 136 | "status": "ok", 137 | "updated": %d, 138 | "dependencies": [ ], 139 | "resources": { } 140 | } 141 | ''' % time() 142 | 143 | resp = make_response(status, 200) 144 | resp.headers['Content-Type'] = 'application/json' 145 | 146 | return resp 147 | 148 | @app.route('/bookmarklet.js') 149 | @errors_logged 150 | def bookmarklet_script(): 151 | if should_redirect(): 152 | return make_redirect() 153 | 154 | js = open('scripts/bookmarklet.js').read() 155 | 156 | script = make_response(js.replace('host:port', request.host), 200) 157 | script.headers['Content-Type'] = 'text/javascript' 158 | script.headers['Cache-Control'] = 'no-store private' 159 | 160 | return script 161 | 162 | @app.route('/oauth/callback') 163 | @errors_logged 164 | def get_oauth_callback(): 165 | ''' Handle Github's OAuth callback after a user authorizes. 166 | 167 | http://developer.github.com/v3/oauth/#github-redirects-back-to-your-site 168 | ''' 169 | if 'error' in request.args: 170 | return render_template('error-oauth.html', reason="you didn't authorize access to your account.") 171 | 172 | try: 173 | code, state_id = request.args['code'], request.args['state'] 174 | except: 175 | return render_template('error-oauth.html', reason='missing code or state in callback.') 176 | 177 | try: 178 | state = session['states'].pop(state_id) 179 | except: 180 | return render_template('error-oauth.html', reason='state "%s" not found?' % state_id) 181 | 182 | # 183 | # Exchange the temporary code for an access token: 184 | # http://developer.github.com/v3/oauth/#parameters-1 185 | # 186 | data = dict(client_id=github_client_id, code=code, client_secret=github_client_secret) 187 | resp = post('https://github.com/login/oauth/access_token', urlencode(data), 188 | headers={'Accept': 'application/json'}) 189 | auth = resp.json() 190 | 191 | if 'error' in auth: 192 | return render_template('error-oauth.html', reason='Github said "%(error)s".' % auth) 193 | 194 | elif 'access_token' not in auth: 195 | return render_template('error-oauth.html', reason="missing `access_token`.") 196 | 197 | session['token'] = auth 198 | 199 | # 200 | # Figure out who's here. 201 | # 202 | url = 'https://api.github.com/user' 203 | id = OAuth2Session(github_client_id, token=session['token']).get(url).json() 204 | id = dict(login=id['login'], avatar_url=id['avatar_url'], html_url=id['html_url']) 205 | session['id'] = id 206 | 207 | other = redirect(state['redirect'], 302) 208 | other.headers['Cache-Control'] = 'no-store private' 209 | other.headers['Vary'] = 'Referer' 210 | 211 | return other 212 | 213 | @app.route('/logout', methods=['POST']) 214 | @errors_logged 215 | def logout(): 216 | ''' 217 | ''' 218 | if 'id' in session: 219 | session.pop('id') 220 | 221 | if 'token' in session: 222 | session.pop('token') 223 | 224 | return redirect('/', 302) 225 | 226 | @app.route('//') 227 | @errors_logged 228 | def repo_only(account, repo): 229 | ''' Redirect to "master" on a hunch. 230 | ''' 231 | if should_redirect(): 232 | return make_redirect() 233 | 234 | return redirect('/%s/%s/master/' % (account, repo), 302) 235 | 236 | @app.route('///') 237 | @errors_logged 238 | def repo_only_slash(account, repo): 239 | ''' Redirect to "master" on a hunch. 240 | ''' 241 | if should_redirect(): 242 | return make_redirect() 243 | 244 | return redirect('/%s/%s/master/' % (account, repo), 302) 245 | 246 | @app.route('///') 247 | @errors_logged 248 | def repo_ref(account, repo, ref): 249 | ''' Redirect to add trailing slash. 250 | ''' 251 | if should_redirect(): 252 | return make_redirect() 253 | 254 | return redirect('/%s/%s/%s/' % (account, repo, ref), 302) 255 | 256 | @app.route('////') 257 | @errors_logged 258 | def repo_ref_slash(account, repo, ref): 259 | ''' Show repository root directory listing. 260 | ''' 261 | if should_redirect(): 262 | return make_redirect() 263 | 264 | try: 265 | site_path = jekyll_build(prepare_git_checkout(account, repo, ref, token=get_token())) 266 | except MissingRepoException: 267 | return make_404_response('no-such-repo.html', dict(account=account, repo=repo)) 268 | except MissingRefException: 269 | return make_404_response('no-such-ref.html', dict(account=account, repo=repo, ref=ref)) 270 | except PrivateRepoException: 271 | return make_401_response() 272 | except RuntimeError, e: 273 | return make_500_response(e, format_exc()) 274 | 275 | return get_directory_response(site_path) 276 | 277 | @app.route('////') 278 | @errors_logged 279 | def repo_ref_path(account, repo, ref, path): 280 | ''' Show response for a path, whether a file or directory. 281 | ''' 282 | if should_redirect(): 283 | return make_redirect() 284 | 285 | try: 286 | site_path = jekyll_build(prepare_git_checkout(account, repo, ref, token=get_token())) 287 | except MissingRepoException: 288 | return make_404_response('no-such-repo.html', dict(account=account, repo=repo)) 289 | except MissingRefException: 290 | return make_404_response('no-such-ref.html', dict(account=account, repo=repo, ref=ref)) 291 | except PrivateRepoException: 292 | return make_401_response() 293 | except RuntimeError, e: 294 | return make_500_response(e, format_exc()) 295 | 296 | local_path = join(site_path, path) 297 | 298 | if isfile(local_path) and not isdir(local_path): 299 | return get_file_response(local_path) 300 | 301 | if isdir(local_path) and not path.endswith('/'): 302 | other = redirect('/%s/%s/%s/%s/' % (account, repo, ref, path), 302) 303 | other.headers['Cache-Control'] = 'no-store private' 304 | return other 305 | 306 | if isdir(local_path): 307 | return get_directory_response(local_path) 308 | 309 | kwargs = dict(account=account, repo=repo, ref=ref, path=path) 310 | return make_404_response('error-404.html', kwargs) 311 | 312 | @app.route('/') 313 | @errors_logged 314 | def all_other_paths(path): 315 | ''' 316 | ''' 317 | if should_redirect(): 318 | return make_redirect() 319 | 320 | if environ.get('app-logfile', None): 321 | handler = FileHandler(environ['app-logfile']) 322 | handler.setFormatter(Formatter('%(process)05s %(asctime)s %(levelname)06s: %(message)s')) 323 | 324 | else: 325 | handler = StreamHandler() 326 | handler.setFormatter(Formatter('%(process)05s %(levelname)06s: %(message)s')) 327 | 328 | getLogger('jekit').addHandler(handler) 329 | 330 | if __name__ == '__main__': 331 | app.run('0.0.0.0', debug=True) 332 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | requests-oauthlib 4 | -------------------------------------------------------------------------------- /scripts/bookmarklet.js: -------------------------------------------------------------------------------- 1 | var href = location.href, 2 | url = false; 3 | 4 | if(href.match(/^https:\/\/github.com\/.+\/.+\/(edit|blob)\//)) 5 | { 6 | if(href.match(/^.+\/_posts\/....-..-..-.+$/)) 7 | { 8 | var url = href.replace(/^.+\/(.+\/.+)\/(edit|blob)\/(.+)\/_posts\/(....)-(..)-(..)-(.+)$/, 9 | 'http://host:port/$1/$3/$4/$5/$6/$7'); 10 | } 11 | else 12 | { 13 | var url = href.replace(/^.+\/(.+\/.+)\/(edit|blob)\/([^\/]+\/.+)$/, 14 | 'http://host:port/$1/$3'); 15 | } 16 | } 17 | else if(href.match(/^http:\/\/prose.io\/#.+\/.+\/edit\//)) 18 | { 19 | if(href.match(/^.+\/_posts\/....-..-..-.+$/)) 20 | { 21 | var url = href.replace(/^.+\/#(.+\/.+)\/edit\/(.+)\/_posts\/(....)-(..)-(..)-(.+)$/, 22 | 'http://host:port/$1/$2/$3/$4/$5/$6'); 23 | } 24 | else 25 | { 26 | var url = href.replace(/^.+\/#(.+\/.+)\/edit\/([^\/]+\/.+)$/, 27 | 'http://host:port/$1/$2'); 28 | } 29 | } 30 | 31 | if(url) 32 | { 33 | if(url && url.match(/\.(md|markdown)$/)) { 34 | url = url.replace(/\.(md|markdown)$/, '.html'); 35 | } 36 | 37 | window.open(url); 38 | } 39 | else 40 | { 41 | alert("Jekit doesn't understand this URL:\n" + href); 42 | } 43 | -------------------------------------------------------------------------------- /templates/error-404.html: -------------------------------------------------------------------------------- 1 | {% extends "page-layout.html" %} 2 | {% block content %} 3 | 4 |

Jekit Error

5 | 6 |

7 | Couldn’t find {{path}} under 8 | {{account}}/{{repo}}/{{ref}}. 9 |
10 | Maybe it exists, 11 | but Jekyll put it at some other location? 12 |

13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /templates/error-oauth.html: -------------------------------------------------------------------------------- 1 | {% extends "page-layout.html" %} 2 | {% block content %} 3 | 4 |

Jekit Error

5 | 6 |

7 | Couldn’t authenticate with Github, sorry: {{reason}} 8 |

9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /templates/error-runtime.html: -------------------------------------------------------------------------------- 1 | {% extends "page-layout.html" %} 2 | {% block content %} 3 | 4 |

Jekit Error

5 | 6 |

7 | Something went wrong building the preview. Here’s what I know: 8 |

9 | 10 |
{{error}}
11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "page-layout.html" %} 2 | {% block content %} 3 | 4 |

Jekit

5 |

6 | Preview your Github-hosted websites before making them live. Use it to 7 | check your plain static or Jekyll-generated 8 | websites before you make them live to Github Pages 9 | or to your own server. 10 |

11 |

12 | Written by Michal Migurski 13 | with Mick Thompson, 14 | © 2013-2014 Code for America. 15 |

16 |

17 | You can view the source code for Jekit on GitHub at https://github.com/codeforamerica/git-jekyll-preview. 18 |

19 |

Check it, before you wreck it.

20 |
    21 |
  1. 22 |

    23 | Preview pages in Jekit by adding your Github account name and repository 24 | name to a Jekit URL: 25 |

    26 |

    27 | {{request.host}}/<account>/<repo>/ 28 |

    29 |

    30 | Example showing the Beyond Transparency book site: 31 |
    {{request.host}}/codeforamerica/beyondtransparency/ 32 |

    33 |
  2. 34 |
  3. 35 |

    36 | To view a commit or branch other than master, add the ref to the 37 | end: 38 |

    39 |

    40 | {{request.host}}/<account>/<repo>/<ref>/ 41 |

    42 |

    43 | Example showing a single Beyond Transparency commit: 44 |
    {{request.host}}/codeforamerica/beyondtransparency/08463604/ 45 |

    46 |
  4. 47 |
  5. 48 |

    49 | To view a specific file or directory, add it after the ref: 50 |

    51 |

    52 | {{request.host}}/<account>/<repo>/<ref>/wow/such-path.html 53 |

    54 |

    55 | Example of the Beyond Transparency preface: 56 |
    {{request.host}}/codeforamerica/beyondtransparency/master/chapters/preface/ 57 |

    58 |
  6. 59 |
60 |

Private Repositories

61 |

62 | Jekit will ask for access to your Github account to preview private 63 | repositories. You can revoke Jekit’s access via your 64 | Github Applications page. 65 | Jekit will keep a copy of each private repository you preview for an 66 | unknown period of time. 67 |

68 | {% if id %} 69 |
70 |

71 | 72 | Jekit knows you as {{id.login}}. 73 | 74 |

75 |
76 | {% endif %} 77 |

Bookmarklet

78 |

79 | From Github 80 | or Prose.io, 81 | drag this to your bookmarks bar to install 82 | the bookmarklet. 83 |

84 |

85 | Preview on {{request.host}} 86 |

87 | 88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /templates/no-such-ref.html: -------------------------------------------------------------------------------- 1 | {% extends "page-layout.html" %} 2 | {% block content %} 3 | 4 |

Jekit Error

5 | 6 |

7 | Couldn’t find {{ref}} under 8 | {{account}}/{{repo}}. 9 | Does it exist? 10 |

11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/no-such-repo.html: -------------------------------------------------------------------------------- 1 | {% extends "page-layout.html" %} 2 | {% block content %} 3 | 4 |

Jekit Error

5 | 6 |

7 | Couldn’t find {{account}}/{{repo}}. 8 | Does it exist? 9 |

10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /templates/page-layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Jekit 6 | 7 | 8 | 9 | 10 | 13 | 16 | 17 | 18 | 47 | 48 | 49 |
50 | 51 | 56 | 57 |
58 |
59 | 60 | {% block content %}{% endblock %} 61 | 62 |
63 |
64 |
65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | from os import utime, stat, listdir 2 | from fcntl import flock, LOCK_EX, LOCK_UN 3 | from contextlib import contextmanager 4 | from subprocess import Popen, PIPE 5 | from mimetypes import guess_type 6 | from traceback import format_exc 7 | from logging import getLogger 8 | from functools import wraps 9 | from os.path import join 10 | from time import time 11 | 12 | from flask import Response 13 | 14 | jlogger = getLogger('jekit') 15 | 16 | @contextmanager 17 | def locked_file(path): 18 | ''' Create a file, lock it, then unlock it. Use as a context manager. 19 | 20 | Yields nothing. 21 | ''' 22 | jlogger.debug('Locking ' + path) 23 | 24 | try: 25 | file = open(path, 'a') 26 | flock(file, LOCK_EX) 27 | 28 | yield 29 | 30 | finally: 31 | jlogger.debug('Unlocking ' + path) 32 | flock(file, LOCK_UN) 33 | 34 | def is_fresh(path): 35 | ''' Return true if path is younger than 10 seconds. 36 | ''' 37 | return stat(path).st_mtime > time() - 10 38 | 39 | def touch(path): 40 | ''' Touch the path to bring its modified timestamp to now. 41 | ''' 42 | jlogger.debug('Touching ' + path) 43 | utime(path, None) 44 | 45 | def run_cmd(args, cwd=None): 46 | ''' Runs a single command in a new process, returns its stdout. 47 | ''' 48 | command = Popen(args, stdout=PIPE, stderr=PIPE, cwd=cwd) 49 | command.wait() 50 | 51 | if command.returncode != 0: 52 | raise RuntimeError(command.stderr.read()) 53 | 54 | return command.stdout.read() 55 | 56 | def get_file_response(path): 57 | ''' Return a flask Response for a simple file. 58 | ''' 59 | mimetype, encoding = guess_type(path) 60 | 61 | with open(path) as file: 62 | return Response(file.read(), headers={'Content-Type': mimetype, 'Cache-Control': 'no-store private'}) 63 | 64 | def get_directory_response(path): 65 | ''' Return a flask Response for a directory listing. 66 | ''' 67 | names = sorted(listdir(path)) 68 | 69 | if 'index.html' in names: 70 | return get_file_response(join(path, 'index.html')) 71 | 72 | items = ['
  • %s
  • ' % (n, n) for n in names] 73 | html = '
      ' + ''.join(items) + '
    ' 74 | 75 | return Response(html, headers={'Content-Type': 'text/html', 'Cache-Control': 'no-store private'}) 76 | 77 | def errors_logged(route_function): 78 | ''' 79 | ''' 80 | @wraps(route_function) 81 | def wrapper(*args, **kwargs): 82 | try: 83 | result = route_function(*args, **kwargs) 84 | except Exception, e: 85 | jlogger.error(format_exc()) 86 | return Response('Nope.', headers={'Content-Type': 'text/plain'}, status=500) 87 | else: 88 | return result 89 | 90 | return wrapper --------------------------------------------------------------------------------