diff --git a/galicea_git/README.md b/galicea_git/README.md new file mode 100644 index 0000000..ccd490f --- /dev/null +++ b/galicea_git/README.md @@ -0,0 +1 @@ +[See add-on page on odoo.com](https://apps.odoo.com/apps/modules/10.0/galicea_git/) diff --git a/galicea_git/__init__.py b/galicea_git/__init__.py new file mode 100644 index 0000000..26e263a --- /dev/null +++ b/galicea_git/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import controllers +from . import models +from . import system_checks diff --git a/galicea_git/__manifest__.py b/galicea_git/__manifest__.py new file mode 100644 index 0000000..63f5e8d --- /dev/null +++ b/galicea_git/__manifest__.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Galicea Git hosting", + + 'summary': """Git repository hosting and per-user access checking""", + + 'author': "Maciej Wawro", + 'maintainer': "Galicea", + 'website': "http://galicea.pl", + + 'category': 'Technical Settings', + 'version': '10.0.1.0', + + 'depends': ['web', 'galicea_environment_checkup'], + + 'external_dependencies': { + 'bin': ['git'] + }, + + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + + 'data/config.xml', + 'views/views.xml', + ], + + 'images': [ + 'static/description/images/create_screenshot.png', + 'static/description/images/config_screenshot.png', + 'static/description/images/console_screenshot.png', + ], + + 'application': True, + 'installable': True, + + 'environment_checkup': { + 'dependencies': { + 'external': [ + { + 'name': 'git', + 'version': '>=2.1.4', + 'install': "apt install git" + } + ] + } + } +} diff --git a/galicea_git/controllers/__init__.py b/galicea_git/controllers/__init__.py new file mode 100644 index 0000000..65a8c12 --- /dev/null +++ b/galicea_git/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import main diff --git a/galicea_git/controllers/main.py b/galicea_git/controllers/main.py new file mode 100644 index 0000000..96abad9 --- /dev/null +++ b/galicea_git/controllers/main.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- + +import subprocess, os, io + +from odoo import http +from odoo.tools import config +import werkzeug + +from ..http_chunked_fix import http_input_stream + +class Main(http.Controller): + @http.route( + [ + '/git/', + '/git//', + ], + auth='public', + csrf=False + ) + def git(self, request, repo, **kw): + auth = request.httprequest.authorization + if auth: + request.session.authenticate(request.session.db, auth.username, auth.password) + if not request.env.uid or request.env.user.login == 'public': + return werkzeug.Response( + headers=[('WWW-Authenticate', 'Basic')], + status=401 + ) + + try: + repository = request.env['galicea_git.repository'].search( + [('system_name', '=', repo)] + ) + except AccessError: + return werkzeug.Response( + status=403 + ) + if not repository.exists(): + return werkzeug.Response( + status=404 + ) + + http_environment = request.httprequest.environ + git_env = { + 'REQUEST_METHOD': http_environment['REQUEST_METHOD'], + 'QUERY_STRING': http_environment['QUERY_STRING'], + 'CONTENT_TYPE': request.httprequest.headers.get('Content-Type'), + 'REMOTE_ADDR': http_environment['REMOTE_ADDR'], + 'GIT_PROJECT_ROOT': os.path.join(config['data_dir'], 'git'), + 'GIT_HTTP_EXPORT_ALL': '1', + 'PATH_INFO': http_environment['PATH_INFO'][4:], + 'REMOTE_USER': request.env.user.login + } + + command_env = os.environ.copy() + for var in git_env: + command_env[var] = git_env[var] + + git = subprocess.Popen( + ['/usr/lib/git-core/git-http-backend'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=command_env, + shell=True + ) + stdout, stderr = git.communicate(http_input_stream(request).read()) + headers_str, body = stdout.split("\r\n\r\n", 2) + + http_status_code = 200 + headers = [] + for header in headers_str.split("\r\n"): + name, value = header.split(': ', 2) + if name == 'Status': + http_code = int(value.split(' ')[0]) + else: + headers.append((name, value)) + + return werkzeug.Response( + body, + status = http_status_code, + headers = headers + ) diff --git a/galicea_git/data/config.xml b/galicea_git/data/config.xml new file mode 100644 index 0000000..1c984af --- /dev/null +++ b/galicea_git/data/config.xml @@ -0,0 +1,9 @@ + + + + galicea_git.git_http_backend + /usr/lib/git-core/git-http-backend + + + + diff --git a/galicea_git/http_chunked_fix.py b/galicea_git/http_chunked_fix.py new file mode 100644 index 0000000..7360c26 --- /dev/null +++ b/galicea_git/http_chunked_fix.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +import io + +# Werkzeug version bundled into odoo doesn't handle this kind of Transfer-Encoding +# correctly. We copy the fix from https://github.com/pallets/werkzeug/pull/1198/files +class DechunkedInput(io.RawIOBase): + """An input stream that handles Transfer-Encoding 'chunked'""" + + def __init__(self, rfile): + self._rfile = rfile + self._done = False + self._len = 0 + + def readable(self): + return True + + def read_chunk_len(self): + try: + line = self._rfile.readline().decode('latin1') + _len = int(line.strip(), 16) + except ValueError: + raise IOError('Invalid chunk header') + if _len < 0: + raise IOError('Negative chunk length not allowed') + return _len + + def readinto(self, buf): + read = 0 + while not self._done and read < len(buf): + if self._len == 0: + # This is the first chunk or we fully consumed the previous + # one. Read the next length of the next chunk + self._len = self.read_chunk_len() + + if self._len == 0: + # Found the final chunk of size 0. The stream is now exhausted, + # but there is still a final newline that should be consumed + self._done = True + + if self._len > 0: + # There is data (left) in this chunk, so append it to the + # buffer. If this operation fully consumes the chunk, this will + # reset self._len to 0. + n = min(len(buf), self._len) + buf[read:read + n] = self._rfile.read(n) + self._len -= n + read += n + + if self._len == 0: + # Skip the terminating newline of a chunk that has been fully + # consumed. This also applies to the 0-sized final chunk + terminator = self._rfile.readline() + if terminator not in (b'\n', b'\r\n', b'\r'): + raise IOError('Missing chunk terminating newline') + + return read + +def http_input_stream(request): + if request.httprequest.headers.get('Transfer-Encoding') == 'chunked' \ + and not request.httprequest.environ.get('wsgi.input_terminated'): + return DechunkedInput(request.httprequest.environ['wsgi.input']) + return request.httprequest.stream diff --git a/galicea_git/models/__init__.py b/galicea_git/models/__init__.py new file mode 100644 index 0000000..2c4eef7 --- /dev/null +++ b/galicea_git/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import repository +from . import config_settings diff --git a/galicea_git/models/config_settings.py b/galicea_git/models/config_settings.py new file mode 100644 index 0000000..e75a501 --- /dev/null +++ b/galicea_git/models/config_settings.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +import os + +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +class ConfigSettings(models.TransientModel): + _name = 'galicea_git.config.settings' + _inherit = 'res.config.settings' + + git_http_backend = fields.Char( + 'Absolute path to Git HTTP backend', + required=True + ) + git_http_backend_valid = fields.Boolean( + compute='_compute_git_http_backend_valid' + ) + + @api.one + @api.depends('git_http_backend') + def _compute_git_http_backend_valid(self): + self.git_http_backend_valid = self.git_http_backend and os.access(self.git_http_backend, os.X_OK) + + @api.one + def set_params(self): + self.env['ir.config_parameter'].set_param('galicea_git.git_http_backend', self.git_http_backend) + + @api.model + def get_default_values(self, fields): + return { + 'git_http_backend': self.env['ir.config_parameter'].get_param('galicea_git.git_http_backend') + } + + @api.multi + def execute(self): + self.ensure_one() + if not self.env.user.has_group('galicea_git.group_admin'): + raise AccessError("Only Git administrators can change those settings") + super(ConfigSettings, self.sudo()).execute() + act_window = self.env.ref('galicea_git.config_settings_action') + return act_window.read()[0] diff --git a/galicea_git/models/repository.py b/galicea_git/models/repository.py new file mode 100644 index 0000000..7a7561f --- /dev/null +++ b/galicea_git/models/repository.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +import os +import random +import shutil +import string +import subprocess + +from odoo import models, fields, api, http +from odoo.exceptions import ValidationError +from odoo.tools import config, which + +class Repository(models.Model): + _name = 'galicea_git.repository' + + state = fields.Selection( + [('draft', 'Draft'), ('created', 'Created')], + default='draft' + ) + + name = fields.Char('User-friendly name', required=True) + system_name = fields.Char( + 'Directory name', + required=True, + readonly=True, + index=True, + states={'draft': [('readonly', False)]} + ) + collaborator_ids = fields.Many2many( + 'res.users', + string='Collaborators' + ) + + local_directory = fields.Char( + 'Local directory on server', + compute='_compute_local_directory', + groups='galicea_git.group_admin' + ) + url = fields.Char( + 'Clone', + compute='_compute_url' + ) + + @api.one + @api.depends('system_name') + def _compute_url(self): + base_url = http.request.httprequest.host_url if http.request \ + else env['ir.config_parameter'].get_param('web.base.url') + '/' + self.url = u'{}git/{}'.format(base_url, self.system_name) + + @api.one + @api.depends('system_name') + def _compute_local_directory(self): + if self.system_name: + self.local_directory = os.path.join(config['data_dir'], 'git', self.system_name) + + @api.constrains('system_name') + def _validate_system_name(self): + allowed_characters = string.ascii_lowercase + string.digits + '-' + if not all(c in allowed_characters for c in self.system_name): + raise ValidationError( + 'Only lowercase, digits and hyphens (-) are allowed in directory name' + ) + + @api.constrains('collaborator_ids') + def _validate_collaborator_ids(self): + invalid_collaborators = self.collaborator_ids.filtered(lambda c: not c.has_group('galicea_git.group_collaborator')) + if invalid_collaborators: + raise ValidationError( + 'User {} does not have the {} role. Contact your Administrator'.format( + invalid_collaborators[0].name, + self.env.ref('galicea_git.group_collaborator').full_name + ) + ) + + @api.model + def create(self, values): + values['state'] = 'created' + ret = super(Repository, self).create(values) + ret.__initialize_repo() + return ret + + @api.multi + def unlink(selfs): + directories_to_move = selfs.mapped(lambda r: r.local_directory) + ret = super(Repository, selfs).unlink() + for directory in directories_to_move: + if os.path.exists(directory): + suffix = ''.join(random.choice(string.ascii_lowercase) for _ in range(8)) + new_directory = directory + '-deleted-' + suffix + shutil.move(directory, new_directory) + + @api.multi + def __initialize_repo(self): + self.ensure_one() + if os.path.exists(self.local_directory): + raise ValidationError( + 'Repository {} already exists, choose a different name!'.format(self.system_name) + ) + subprocess.check_call([which('git'), 'init', '--bare', self.local_directory]) diff --git a/galicea_git/security/ir.model.access.csv b/galicea_git/security/ir.model.access.csv new file mode 100644 index 0000000..e643b3c --- /dev/null +++ b/galicea_git/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_repository_collaborator,repository_collaborator,model_galicea_git_repository,galicea_git.group_collaborator,1,0,0,0 +access_repository_admin,repository_admin,model_galicea_git_repository,galicea_git.group_admin,1,1,1,1 diff --git a/galicea_git/security/security.xml b/galicea_git/security/security.xml new file mode 100644 index 0000000..2e6a06e --- /dev/null +++ b/galicea_git/security/security.xml @@ -0,0 +1,47 @@ + + + + Git + + + + Collaborator + + + + + Administrator + + + + + + + + + + Collaborators can only access repositories they are assigned to + + + + [('collaborator_ids', 'in', user.id)] + + + + + + + + + Administrators can access any repositories + + + + [(1, '=', 1)] + + + + + + + diff --git a/galicea_git/static/description/icon.png b/galicea_git/static/description/icon.png new file mode 100644 index 0000000..a9ec073 Binary files /dev/null and b/galicea_git/static/description/icon.png differ diff --git a/galicea_git/static/description/images/config_screenshot.png b/galicea_git/static/description/images/config_screenshot.png new file mode 100644 index 0000000..73fdc44 Binary files /dev/null and b/galicea_git/static/description/images/config_screenshot.png differ diff --git a/galicea_git/static/description/images/console_screenshot.png b/galicea_git/static/description/images/console_screenshot.png new file mode 100644 index 0000000..f101586 Binary files /dev/null and b/galicea_git/static/description/images/console_screenshot.png differ diff --git a/galicea_git/static/description/images/create_screenshot.png b/galicea_git/static/description/images/create_screenshot.png new file mode 100644 index 0000000..ea7c8d2 Binary files /dev/null and b/galicea_git/static/description/images/create_screenshot.png differ diff --git a/galicea_git/static/description/index.html b/galicea_git/static/description/index.html new file mode 100644 index 0000000..48169bb --- /dev/null +++ b/galicea_git/static/description/index.html @@ -0,0 +1,19 @@ +
+
+
+

Galicea Git hosting

+

+ Simple Odoo-based HTTP interface for Git repository hosting +

+

+ This add-on allows you to create Git repositories hosted by Odoo, and add specific Odoo users as collaborators. Only those users will have access to the repository. It requires git package, including git-http-backend, installed in the system. For Ubuntu/Debian it's enough to call +

sudo apt install git
+

+

Creating repositories

+ + +

Interacting with the repository

+ +
+
+
diff --git a/galicea_git/system_checks.py b/galicea_git/system_checks.py new file mode 100644 index 0000000..95af9aa --- /dev/null +++ b/galicea_git/system_checks.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +import os + +from odoo.addons.galicea_environment_checkup import \ + custom_check, CheckWarning, CheckSuccess, CheckFail +from odoo import http + +@custom_check +def check_single_db(env): + if not http.request: + raise CheckWarning('Could not detect DB settings.') + + dbs = http.db_list(True, http.request.httprequest) + if len(dbs) == 1: + return CheckSuccess('Odoo runs in a single-DB mode.') + + details = ( + '

Odoo runs in a multi-DB mode, which will cause Git HTTP requests to fail.

' + '

Run Odoo with --dbfilter or --database flag.

' + ) + return CheckFail( + 'Odoo runs in a multi-DB mode.', + details=details + ) + +@custom_check +def check_http_backend(env): + backend_path = env['ir.config_parameter'].sudo().get_param( + 'galicea_git.git_http_backend' + ) + if not os.access(backend_path, os.X_OK): + raise CheckFail( + 'Git HTTP backend not found', + details='Check the configuration here' + ) + return CheckSuccess('Git HTTP backend was found') diff --git a/galicea_git/views/views.xml b/galicea_git/views/views.xml new file mode 100644 index 0000000..86d808d --- /dev/null +++ b/galicea_git/views/views.xml @@ -0,0 +1,66 @@ + + + galicea_git.repository + +
+ + + + + + + + +
+
+
+ + + galicea_git.repository + + + + + + + + + + + + + galicea_git.config.settings + +
+
+
+ + +