diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33110cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +node_modules/ +.cache +*.egg-info/ +*.egg +.eggs/ +.tmp/ +.installed.cfg +db.sqlite3 diff --git a/izanami/__init__.py b/izanami/__init__.py new file mode 100644 index 0000000..cbce5a3 --- /dev/null +++ b/izanami/__init__.py @@ -0,0 +1,7 @@ +from mitama.app import Builder + +from .main import App + + +class AppBuilder(Builder): + app = App diff --git a/izanami/controller.py b/izanami/controller.py new file mode 100644 index 0000000..8035442 --- /dev/null +++ b/izanami/controller.py @@ -0,0 +1,119 @@ +from mitama.app import Controller +from mitama.app.http import Response +from mitama.models import User, Group +from .model import Repo +from . import gitHttpBackend + +import git +import os +import shutil + + +class RepoController(Controller): + def handle(self, request): + template = self.view.get_template("repo/list.html") + repos = Repo.list() + return Response.render(template, { + 'repos': repos + }) + def create(self, request): + template = self.view.get_template("repo/create.html") + nodes = [ + *Group.list(), + *User.list() + ] + try: + if request.method == 'POST': + body = request.post() + repo = Repo() + repo.name = body['name'] + repo.owner = body['owner'] + repo.create() + git.Repo.init( + self.app.project_dir / ('repos/' + repo.name + '.git'), + bare = True + ) + return Response.redirect(self.app.convert_url('/'+repo.name)) + except Exception as err: + error = str(err) + print(error) + return Response.render(template, { + 'post': body, + 'error': error, + 'nodes': nodes + }) + return Response.render(template, { + 'post': dict(), + 'nodes': nodes + }) + def update(self, request): + template = self.view.get_template("repo/update.html") + repo = Repo.retrieve(name = request.params['repo']) + try: + if request.method == 'POST': + body = request.post() + name = repo.name + repo.name = body['name'] + repo.owner = body['owner'] + repo.update() + os.rename( + self.app.project_dir / ('repos/' + name + '.git'), + self.app.project_dir / ('repos/' + repo.name + '.git') + ) + except Exception as err: + error = str(err) + return Response.render(template, { + 'repo': repo, + 'error': error + }) + return Response.render(template, { + 'repo': repo + }) + def delete(self, request): + template = self.view.get_template("repo/delete.html") + repo = Repo.retrieve(name = request.params['repo']) + try: + if request.method == 'POST': + if not request.user.password_check(request.post()['password']): + raise AuthorizationError('wrong password') + shutil.rmtree(self.app.project_dir / ('repos/' + repo.name + '.git')) + repo.delete() + return Response.redirect(self.app.convert_url('/')) + except Exception as err: + error = str(err) + return Response.render(template, { + 'repo': repo, + 'error': error + }) + return Response.render(template, { + 'repo': repo + }) + def retrieve(self, request): + template = self.view.get_template("repo/retrieve.html") + repo = Repo.retrieve(name = request.params['repo']) + return Response.render(template, { + 'repo': repo + }) + +class ProxyController(Controller): + def handle(self, request): + repo = Repo.retrieve(name = request.params['repo'][:-4]) + if repo.owner._id != request.user._id and (isinstance(repo.owner, Group) and not repo.owner.is_in(request.user)): + return Response(status=401, reason='Unauthorized', text='You are not the owner of the repository.') + environ = dict(request.environ) + environ['REQUEST_METHOD'] = request.method + environ['PATH_INFO'] = self.app.revert_url(environ['PATH_INFO']) + ( + status, + reason, + headers, + body + ) = gitHttpBackend.wsgi_to_git_http_backend(environ, self.app.project_dir / 'repos') + content_type = headers['Content-Type'] + return Response( + body = body, + status = status, + reason = reason, + headers = headers, + content_type = content_type + ) diff --git a/izanami/gitHttpBackend.py b/izanami/gitHttpBackend.py new file mode 100644 index 0000000..eacf57f --- /dev/null +++ b/izanami/gitHttpBackend.py @@ -0,0 +1,224 @@ +"""Utility functions to invoke git-http-backend +""" + +import subprocess +import threading + + +DEFAULT_CHUNK_SIZE = 0x8000 +DEFAULT_MAX_HEADER_SIZE = 0X20000 # No header should ever be this large. +# TODO: expose these sizes to API +CRLF = b'\r\n' +HEADER_END = CRLF * 2 + + +def wsgi_to_git_http_backend(wsgi_environ, + git_project_root, + user=None): + """Convenience wrapper for how a WSGI application can use this + module to handle a request. + + See build_cgi_environ regarding git_project_root and user. + + See run_git_http_backend for requirements for wsgi.input + and wsgi.errors.""" + cgi_environ = build_cgi_environ(wsgi_environ, git_project_root, user) + input_stream = wsgi_environ['wsgi.input'] + error_stream = wsgi_environ['wsgi.errors'] + cgi_header, response_body_generator = run_git_http_backend( + cgi_environ, input_stream, error_stream + ) + status_line, list_of_headers = parse_cgi_header(cgi_header) + status, reason = status_line.split(' ') + return status, reason, list_of_headers, response_body_generator + + +def run_git_http_backend(cgi_environ, input_stream, error_stream): + """Execute "git http-backend" as a CGI script, using the supplied + environment and the file-like object input_stream. + + See build_cgi_environ() and git documentation for the requirements + for cgi_environ . + + input_stream can be any object implementing the file protocol. Note + that input_stream will be closed here. + + Any stderr generated by the git process will be piped to error_stream, + which must have a file descriptor. + + Return (cgi_header, response_body_generator). The cgi_header is the + string of raw headers returned by git ending with just one CRLF. The + response sent back to the client will need an additional blank line + separating this from the response body. + + Raise EnvironmentError (errno 1) if a CGI/HTTP header is not returned + from git http-backend.""" + input_length = int(cgi_environ.get('CONTENT_LENGTH', '') or 0) + proc = subprocess.Popen( + ['git', 'http-backend'], + bufsize=DEFAULT_CHUNK_SIZE, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=error_stream, + env=cgi_environ + ) + cgi_header, response_body_generator = _communicate_with_git( + proc, input_stream, input_length + ) + return cgi_header, response_body_generator + + +def build_cgi_environ(wsgi_environ, git_project_root, user=None): + """Build a CGI environ from a WSGI environment: + + CONTENT_TYPE + GIT_PROJECT_ROOT = directory containing bare repos + PATH_INFO (if GIT_PROJECT_ROOT is set, otherwise PATH_TRANSLATED) + QUERY_STRING + REMOTE_USER + REMOTE_ADDR + REQUEST_METHOD + + The git_project_root parameter must point to a directory that contains + the git bare repo designated by PATH_INFO. See the git documentation. + + The git repo (my-repo.git) is located at GIT_PROJECT_ROOT + PATH_INFO + (if GIT_PROJECT_ROOT is defined) or at PATH_TRANSLATED. + + If REMOTE_USER is set in wsgi_environ, you should normally leave user + alone. + """ + cgi_environ_ = dict(wsgi_environ) + cgi_environ = dict() + for key, value in cgi_environ_.items(): # NOT iteritems, due to "del" + if isinstance(value, str): + cgi_environ[key] = value + cgi_environ['GIT_HTTP_EXPORT_ALL'] = '1' + cgi_environ['GIT_PROJECT_ROOT'] = git_project_root + if user: + cgi_environ['REMOTE_USER'] = user + cgi_environ.setdefault('REMOTE_USER', 'unknown') + return cgi_environ + + +def parse_cgi_header(cgi_header): + """Given the raw header returned by the CGI, return + (status_line, list_of_headers). This adapts the CGI header + to WSGI conventions.""" + header_dict = {} + raw_lines = cgi_header.split(CRLF) + assert raw_lines[-1] == b'' + for raw_line in raw_lines[:-1]: + name, padded_value = raw_line.strip().split(b':', 1) + value = padded_value.strip() + header_dict[name.decode()] = value.decode() + status_line = header_dict.pop('Status', None) or '200 OK' + #list_of_headers = [(name, header_dict[name]) for name in names] + #return status_line, list_of_headers + return status_line, header_dict + + +def _communicate_with_git(proc, input_stream, input_length): + # Given a subprocess.Popen object: + # * Start writing request data + # * Start reading stdout and possibly stderr + # * Extract the cgi_header + # * Construct a generator for everything that comes after the header + # * Return (cgi_header, response_body_generator) + # (The generator is responsible for extracting all data and cleaning up.) + # Raise EnvironmentError (errno 1) if header is not returned from proc. + threading.Thread(target=_input_data_pump, + args=(proc, input_stream, input_length)).start() + chunks = [b''] # Dummy str at start helps here. + header_end = None + while not header_end: + total_bytes_read = sum(map(len, chunks)) + if total_bytes_read > DEFAULT_MAX_HEADER_SIZE: + raise EnvironmentError( + 1, + 'Read %d bytes from "git http-backend" without ' + 'finding header boundary.' % total_bytes_read, + ) # TODO: Test this. + chuck_data = proc.stdout.read(DEFAULT_CHUNK_SIZE) + if not chuck_data: + raise EnvironmentError( + 1, + 'Did not find header boundary in response ' + 'from "git http-backend".', + ) # TODO: Test this. + chunks.append(chuck_data) + # Search the two most recent chunks for the end of the header. + # header_end -> (header_end_on_boundary, index_within_chunk) or None + header_end = _find_header_end_in_2_chunks(*chunks[-2:]) + header_end_on_boundary, index_within_chunk = header_end + cgi_header, remainder = _separate_header( + chunks, header_end_on_boundary, index_within_chunk + ) + response_body_generator = _response_body_generator(remainder, proc) + return cgi_header, response_body_generator + + +def _input_data_pump(proc, input_stream, input_length): + # Thread for feeding input to git + # TODO: Currently using threads due to lack of universal standard for + # async event loops in web applications. + bytes_read = 0 + while bytes_read < input_length: + bytes_to_read = min(DEFAULT_CHUNK_SIZE, input_length - bytes_read) + current_data = input_stream.read(bytes_to_read) + bytes_read += len(current_data) + proc.stdin.write(current_data) + proc.stdin.close() + + +def _find_header_end_in_2_chunks(chunk0, chunk1): + # Search for the 4-byte HEADER_END in either the end of the first chunk + # (with the 4-byte boundary stretching into the second chunk) or within + # the second chunk starting at 0. + # Return as (header_end_on_boundary, index_within_chunk). + # Return None if header end not found. + boundary_string = chunk0[-3:] + chunk1[:3] + header_end = _search_str_for_header_end(boundary_string) + if header_end != -1: + return True, len(chunk0) - 3 + header_end + header_end = _search_str_for_header_end(chunk1) + if header_end != -1: + return False, header_end + return None + + +def _search_str_for_header_end(data_str): + """Return index of header end or -1.""" + return data_str.find(HEADER_END) + + +def _separate_header(chunks, header_end_on_boundary, index_within_chunk): + # Return header, remainder + if header_end_on_boundary: + # Header ends within chunks[-2] + header_chunks = chunks[:-2] + last_header_chunk = chunks[-2] + body_start_index = (4 - (len(last_header_chunk) - index_within_chunk)) + else: + # Header ends within chunks[-1] + header_chunks = chunks[:-1] + last_header_chunk = chunks[-1] + body_start_index = index_within_chunk + 4 + header_chunks.append(last_header_chunk[:index_within_chunk]) + header_chunks.append(CRLF) # Line end might have been split. + header = b''.join(header_chunks) + remainder = chunks[-1][body_start_index:] + return header, remainder + + +def _response_body_generator(remainder, proc): + # The generator returned up the stack to the WSGI application. + # Yields chunks of data from the subprocess output. + yield remainder + current_data = proc.stdout.read(DEFAULT_CHUNK_SIZE) + while current_data: + yield current_data + current_data = proc.stdout.read(DEFAULT_CHUNK_SIZE) + # TODO: Do we need this? + while proc.poll() is None: + yield b'' diff --git a/izanami/main.py b/izanami/main.py new file mode 100644 index 0000000..2244e97 --- /dev/null +++ b/izanami/main.py @@ -0,0 +1,26 @@ +from mitama.app import App, Router +from mitama.utils.controllers import static_files +from mitama.utils.middlewares import BasicMiddleware, SessionMiddleware +from mitama.app.method import view + +from .controller import RepoController, ProxyController + + +class App(App): + # name = 'MyApp' + # description = 'This is my App.' + router = Router( + [ + Router([ + view("/", ProxyController), + ], middlewares = [BasicMiddleware]), + Router([ + view("/", RepoController), + view("/create", RepoController, 'create'), + view("/", RepoController, 'retrieve'), + view("//update", RepoController, 'update'), + view("//delete", RepoController, 'delete'), + view("/static/", static_files()), + ], middlewares = [SessionMiddleware]) + ] + ) diff --git a/izanami/model.py b/izanami/model.py new file mode 100644 index 0000000..cc1b987 --- /dev/null +++ b/izanami/model.py @@ -0,0 +1,15 @@ +from mitama.db import BaseDatabase +from mitama.db.types import * + + +class Database(BaseDatabase): + pass + + +db = Database() + +class Repo(db.Model): + name = Column(String, primary_key=True, unique=True) + owner = Column(Node, nullable=False) + +db.create_all() diff --git a/izanami/static/vertical-logo.svg b/izanami/static/vertical-logo.svg new file mode 100755 index 0000000..934b7f4 --- /dev/null +++ b/izanami/static/vertical-logo.svg @@ -0,0 +1,88 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/izanami/templates/base.html b/izanami/templates/base.html new file mode 100644 index 0000000..a53f808 --- /dev/null +++ b/izanami/templates/base.html @@ -0,0 +1,12 @@ + + + + + + +
+ {% block content %} + {% endblock %} +
+ + diff --git a/izanami/templates/repo/create.html b/izanami/templates/repo/create.html new file mode 100644 index 0000000..c98de76 --- /dev/null +++ b/izanami/templates/repo/create.html @@ -0,0 +1,21 @@ +{% extends "base.html"%} +{% block content %} +
+
+
+
リポジトリ名
+
+
オーナー
+
+ +
+
+

{{error}}

+ +
+
+{% endblock %} diff --git a/izanami/templates/repo/delete.html b/izanami/templates/repo/delete.html new file mode 100644 index 0000000..70916db --- /dev/null +++ b/izanami/templates/repo/delete.html @@ -0,0 +1,13 @@ +{% extends "base.html"%} +{% block content %} +
+
+

{{repo.name}}を削除します

+
+ +
+

{{error}}

+ +
+
+{% endblock %} diff --git a/izanami/templates/repo/list.html b/izanami/templates/repo/list.html new file mode 100644 index 0000000..fbfc380 --- /dev/null +++ b/izanami/templates/repo/list.html @@ -0,0 +1,14 @@ +{% extends "base.html"%} +{% block content %} +
+ + + + {% for repo in repos %} + +
{{repo.name}}
+
{{repo.owner.name}}
+
+ {% endfor %} +
+{% endblock %} diff --git a/izanami/templates/repo/retrieve.html b/izanami/templates/repo/retrieve.html new file mode 100644 index 0000000..8465566 --- /dev/null +++ b/izanami/templates/repo/retrieve.html @@ -0,0 +1,7 @@ +{% extends "base.html"%} +{% block content %} +
+

{{repo.name}}

+
{{repo.owner.name}}
+
+{% endblock %} diff --git a/izanami/templates/repo/update.html b/izanami/templates/repo/update.html new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..aa63d0f --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +#!/usr/bin/python + +from setuptools import find_packages, setup + +setup( + name="izanami", + install_requires=[ + "mitama", + "GitPython" + ], + extra_requires={"develop": ["unittest", "flake8", "isort", "black"]}, + use_scm_version=True, + setup_requires=["setuptools_scm"], + packages=find_packages(), + package_data={ + "izanami": [ + "templates/*.html", + "templates/**/*.html", + "static/*", + ] + } +)