Compare commits
2 Commits
c56bf4fb49
...
a5aac9a337
| Author | SHA1 | Date | |
|---|---|---|---|
| a5aac9a337 | |||
| ee40c905d4 |
@ -31,7 +31,8 @@ dependencies = [
|
||||
"emoji",
|
||||
"aiofiles",
|
||||
"PyJWT",
|
||||
"multiavatar"
|
||||
"multiavatar",
|
||||
"gitpython",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import argparse
|
||||
|
||||
import uvloop
|
||||
from aiohttp import web
|
||||
|
||||
import asyncio
|
||||
from snek.app import Application
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run the web application.")
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
|
||||
@ -39,6 +39,10 @@ from snek.view.logout import LogoutView
|
||||
from snek.view.register import RegisterView
|
||||
from snek.view.rpc import RPCView
|
||||
from snek.view.search_user import SearchUserView
|
||||
from snek.view.settings.repositories import RepositoriesIndexView
|
||||
from snek.view.settings.repositories import RepositoriesCreateView
|
||||
from snek.view.settings.repositories import RepositoriesUpdateView
|
||||
from snek.view.settings.repositories import RepositoriesDeleteView
|
||||
from snek.view.settings.index import SettingsIndexView
|
||||
from snek.view.settings.profile import SettingsProfileView
|
||||
from snek.view.stats import StatsView
|
||||
@ -48,6 +52,7 @@ from snek.view.upload import UploadView
|
||||
from snek.view.user import UserView
|
||||
from snek.view.web import WebView
|
||||
from snek.webdav import WebdavApplication
|
||||
from snek.sgit import GitApplication
|
||||
|
||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||
|
||||
@ -175,13 +180,14 @@ class Application(BaseApplication):
|
||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||
self.router.add_view("/stats.json", StatsView)
|
||||
self.router.add_view("/user/{user}.html", UserView)
|
||||
self.router.add_view("/settings/repositories/index.html", RepositoriesIndexView)
|
||||
self.router.add_view("/settings/repositories/create.html", RepositoriesCreateView)
|
||||
self.router.add_view("/settings/repositories/repository/{name}/update.html", RepositoriesUpdateView)
|
||||
self.router.add_view("/settings/repositories/respository/{name}/delete.html", RepositoriesDeleteView)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
self.add_subapp("/webdav", self.webdav)
|
||||
|
||||
self.add_subapp(
|
||||
"/docs",
|
||||
DocsApplication(path=pathlib.Path(__file__).parent.joinpath("docs")),
|
||||
)
|
||||
self.add_subapp("/git",self.git)
|
||||
|
||||
#self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ from snek.mapper.drive_item import DriveItemMapper
|
||||
from snek.mapper.notification import NotificationMapper
|
||||
from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.repository import RepositoryMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@ -23,6 +24,7 @@ def get_mappers(app=None):
|
||||
"drive_item": DriveItemMapper(app=app),
|
||||
"drive": DriveMapper(app=app),
|
||||
"user_property": UserPropertyMapper(app=app),
|
||||
"repository": RepositoryMapper(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
7
src/snek/mapper/repository.py
Normal file
7
src/snek/mapper/repository.py
Normal file
@ -0,0 +1,7 @@
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.system.mapper import BaseMapper
|
||||
|
||||
|
||||
class RepositoryMapper(BaseMapper):
|
||||
model_class = RepositoryModel
|
||||
table_name = "repository"
|
||||
@ -10,6 +10,7 @@ from snek.model.drive_item import DriveItemModel
|
||||
from snek.model.notification import NotificationModel
|
||||
from snek.model.user import UserModel
|
||||
from snek.model.user_property import UserPropertyModel
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@ -25,6 +26,7 @@ def get_models():
|
||||
"drive": DriveModel,
|
||||
"notification": NotificationModel,
|
||||
"user_property": UserPropertyModel,
|
||||
"repository": RepositoryModel,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
14
src/snek/model/repository.py
Normal file
14
src/snek/model/repository.py
Normal file
@ -0,0 +1,14 @@
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.model import BaseModel, ModelField
|
||||
|
||||
|
||||
class RepositoryModel(BaseModel):
|
||||
|
||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||
|
||||
name = ModelField(name="name", required=True, kind=str)
|
||||
|
||||
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ from snek.service.socket import SocketService
|
||||
from snek.service.user import UserService
|
||||
from snek.service.user_property import UserPropertyService
|
||||
from snek.service.util import UtilService
|
||||
from snek.service.repository import RepositoryService
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@ -29,6 +30,7 @@ def get_services(app):
|
||||
"drive": DriveService(app=app),
|
||||
"drive_item": DriveItemService(app=app),
|
||||
"user_property": UserPropertyService(app=app),
|
||||
"repository": RepositoryService(app=app),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
37
src/snek/service/repository.py
Normal file
37
src/snek/service/repository.py
Normal file
@ -0,0 +1,37 @@
|
||||
from snek.system.service import BaseService
|
||||
import asyncio
|
||||
|
||||
class RepositoryService(BaseService):
|
||||
mapper_name = "repository"
|
||||
|
||||
async def exists(self, user_uid, name, **kwargs):
|
||||
kwargs["user_uid"] = user_uid
|
||||
kwargs["name"] = name
|
||||
return await super().exists(**kwargs)
|
||||
|
||||
async def init(self, user_uid, name):
|
||||
repository_path = await self.services.user.get_repository_path(user_uid)
|
||||
if not repository_path.exists():
|
||||
repository_path.mkdir(parents=True)
|
||||
repository_path = repository_path.joinpath(name)
|
||||
command = ['git', 'init', '--bare', repository_path]
|
||||
process = await asyncio.subprocess.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
return process.returncode == 0
|
||||
|
||||
async def create(self, user_uid, name,is_private=False):
|
||||
if await self.exists(user_uid=user_uid, name=name):
|
||||
return False
|
||||
|
||||
if not await self.init(user_uid=user_uid, name=name):
|
||||
return False
|
||||
|
||||
model = await self.new()
|
||||
model["user_uid"] = user_uid
|
||||
model["name"] = name
|
||||
model["is_private"] = is_private
|
||||
return await self.save(model)
|
||||
@ -42,6 +42,9 @@ class UserService(BaseService):
|
||||
def get_admin_uids(self):
|
||||
return self.mapper.get_admin_uids()
|
||||
|
||||
async def get_repository_path(self, user_uid):
|
||||
return pathlib.Path(f"./drive/repositories/{user_uid}")
|
||||
|
||||
async def get_static_path(self, user_uid):
|
||||
path = pathlib.Path(f"./drive/{user_uid}/snek/static")
|
||||
if not path.exists():
|
||||
|
||||
472
src/snek/sgit.py
Normal file
472
src/snek/sgit.py
Normal file
@ -0,0 +1,472 @@
|
||||
import os
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import git
|
||||
import shutil
|
||||
import json
|
||||
import tempfile
|
||||
import asyncio
|
||||
import logging
|
||||
import base64
|
||||
import pathlib
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger('git_server')
|
||||
|
||||
class GitApplication(web.Application):
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
super().__init__(client_max_size=100*1024*1024)
|
||||
self.REPO_DIR = "drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545"
|
||||
self.USERS = {
|
||||
'x': 'x',
|
||||
'bob': 'bobpass',
|
||||
}
|
||||
self.add_routes([
|
||||
web.post('/create/{repo_name}', self.create_repository),
|
||||
web.delete('/delete/{repo_name}', self.delete_repository),
|
||||
web.get('/clone/{repo_name}', self.clone_repository),
|
||||
web.post('/push/{repo_name}', self.push_repository),
|
||||
web.post('/pull/{repo_name}', self.pull_repository),
|
||||
web.get('/status/{repo_name}', self.status_repository),
|
||||
web.get('/list', self.list_repositories),
|
||||
web.get('/branches/{repo_name}', self.list_branches),
|
||||
web.post('/branches/{repo_name}', self.create_branch),
|
||||
web.get('/log/{repo_name}', self.commit_log),
|
||||
web.get('/file/{repo_name}/{file_path:.*}', self.file_content),
|
||||
web.get('/{path:.+}/info/refs', self.git_smart_http),
|
||||
web.post('/{path:.+}/git-upload-pack', self.git_smart_http),
|
||||
web.post('/{path:.+}/git-receive-pack', self.git_smart_http),
|
||||
web.get('/{repo_name}.git/info/refs', self.git_smart_http),
|
||||
web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),
|
||||
web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),
|
||||
])
|
||||
|
||||
async def check_basic_auth(self, request):
|
||||
# For now, always return the fixed user and path
|
||||
return "retoor", pathlib.Path(self.REPO_DIR)
|
||||
|
||||
@staticmethod
|
||||
def require_auth(handler):
|
||||
async def wrapped(self, request, *args, **kwargs):
|
||||
username, repository_path = await self.check_basic_auth(request)
|
||||
if not username or not repository_path:
|
||||
return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')
|
||||
request['username'] = username
|
||||
request['repository_path'] = repository_path
|
||||
return await handler(self, request, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
def repo_path(self, repository_path, repo_name):
|
||||
return repository_path.joinpath(repo_name + '.git')
|
||||
|
||||
def check_repo_exists(self, repository_path, repo_name):
|
||||
repo_dir = self.repo_path(repository_path, repo_name)
|
||||
if not os.path.exists(repo_dir):
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
return None
|
||||
|
||||
@require_auth
|
||||
async def create_repository(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
if not repo_name or '/' in repo_name or '..' in repo_name:
|
||||
return web.Response(text="Invalid repository name", status=400)
|
||||
repo_dir = self.repo_path(repository_path, repo_name)
|
||||
if os.path.exists(repo_dir):
|
||||
return web.Response(text="Repository already exists", status=400)
|
||||
try:
|
||||
git.Repo.init(repo_dir, bare=True)
|
||||
logger.info(f"Created repository: {repo_name} for user {username}")
|
||||
return web.Response(text=f"Created repository {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating repository {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error creating repository: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def delete_repository(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
try:
|
||||
shutil.rmtree(self.repo_path(repository_path, repo_name))
|
||||
logger.info(f"Deleted repository: {repo_name} for user {username}")
|
||||
return web.Response(text=f"Deleted repository {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error deleting repository: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def clone_repository(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
host = request.host
|
||||
clone_url = f"http://{host}/{repo_name}.git"
|
||||
response_data = {
|
||||
"repository": repo_name,
|
||||
"clone_command": f"git clone {clone_url}",
|
||||
"clone_url": clone_url
|
||||
}
|
||||
return web.json_response(response_data)
|
||||
|
||||
@require_auth
|
||||
async def push_repository(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(text="Invalid JSON data", status=400)
|
||||
commit_message = data.get('commit_message', 'Update from server')
|
||||
branch = data.get('branch', 'main')
|
||||
changes = data.get('changes', [])
|
||||
if not changes:
|
||||
return web.Response(text="No changes provided", status=400)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
for change in changes:
|
||||
file_path = os.path.join(temp_dir, change.get('file', ''))
|
||||
content = change.get('content', '')
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content)
|
||||
temp_repo.git.add(A=True)
|
||||
if not temp_repo.config_reader().has_section('user'):
|
||||
temp_repo.config_writer().set_value("user", "name", "Git Server").release()
|
||||
temp_repo.config_writer().set_value("user", "email", "git@server.local").release()
|
||||
temp_repo.index.commit(commit_message)
|
||||
origin = temp_repo.remote('origin')
|
||||
origin.push(refspec=f"{branch}:{branch}")
|
||||
logger.info(f"Pushed to repository: {repo_name} for user {username}")
|
||||
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
||||
|
||||
@require_auth
|
||||
async def pull_repository(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
remote_url = data.get('remote_url')
|
||||
branch = data.get('branch', 'main')
|
||||
if not remote_url:
|
||||
return web.Response(text="Remote URL is required", status=400)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
remote_name = "pull_source"
|
||||
try:
|
||||
remote = local_repo.create_remote(remote_name, remote_url)
|
||||
except git.GitCommandError:
|
||||
remote = local_repo.remote(remote_name)
|
||||
remote.set_url(remote_url)
|
||||
remote.fetch()
|
||||
local_repo.git.merge(f"{remote_name}/{branch}")
|
||||
origin = local_repo.remote('origin')
|
||||
origin.push()
|
||||
logger.info(f"Pulled to repository {repo_name} from {remote_url} for user {username}")
|
||||
return web.Response(text=f"Successfully pulled changes from {remote_url} to {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error pulling to {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error pulling changes: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def status_repository(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
branches = [b.name for b in temp_repo.branches]
|
||||
active_branch = temp_repo.active_branch.name
|
||||
commits = []
|
||||
for commit in list(temp_repo.iter_commits(max_count=5)):
|
||||
commits.append({
|
||||
"id": commit.hexsha,
|
||||
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||
"date": commit.committed_datetime.isoformat(),
|
||||
"message": commit.message
|
||||
})
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk(temp_dir):
|
||||
if '.git' in root:
|
||||
continue
|
||||
for filename in filenames:
|
||||
full_path = os.path.join(root, filename)
|
||||
rel_path = os.path.relpath(full_path, temp_dir)
|
||||
files.append(rel_path)
|
||||
status_info = {
|
||||
"repository": repo_name,
|
||||
"branches": branches,
|
||||
"active_branch": active_branch,
|
||||
"recent_commits": commits,
|
||||
"files": files
|
||||
}
|
||||
return web.json_response(status_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting status for {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error getting repository status: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def list_repositories(self, request):
|
||||
username = request['username']
|
||||
try:
|
||||
repos = []
|
||||
user_dir = self.REPO_DIR
|
||||
if os.path.exists(user_dir):
|
||||
for item in os.listdir(user_dir):
|
||||
item_path = os.path.join(user_dir, item)
|
||||
if os.path.isdir(item_path) and item.endswith('.git'):
|
||||
repos.append(item[:-4])
|
||||
if request.query.get('format') == 'json':
|
||||
return web.json_response({"repositories": repos})
|
||||
else:
|
||||
return web.Response(text="\n".join(repos) if repos else "No repositories found")
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing repositories: {str(e)}")
|
||||
return web.Response(text=f"Error listing repositories: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def list_branches(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
branches = [b.name for b in temp_repo.branches]
|
||||
return web.json_response({"branches": branches})
|
||||
|
||||
@require_auth
|
||||
async def create_branch(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(text="Invalid JSON data", status=400)
|
||||
branch_name = data.get('branch_name')
|
||||
start_point = data.get('start_point', 'HEAD')
|
||||
if not branch_name:
|
||||
return web.Response(text="Branch name is required", status=400)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
temp_repo.git.branch(branch_name, start_point)
|
||||
temp_repo.git.push('origin', branch_name)
|
||||
logger.info(f"Created branch {branch_name} in repository {repo_name} for user {username}")
|
||||
return web.Response(text=f"Created branch {branch_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating branch {branch_name} in {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error creating branch: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def commit_log(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
try:
|
||||
limit = int(request.query.get('limit', 10))
|
||||
branch = request.query.get('branch', 'main')
|
||||
except ValueError:
|
||||
return web.Response(text="Invalid limit parameter", status=400)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
commits = []
|
||||
try:
|
||||
for commit in list(temp_repo.iter_commits(branch, max_count=limit)):
|
||||
commits.append({
|
||||
"id": commit.hexsha,
|
||||
"short_id": commit.hexsha[:7],
|
||||
"author": f"{commit.author.name} <{commit.author.email}>",
|
||||
"date": commit.committed_datetime.isoformat(),
|
||||
"message": commit.message.strip()
|
||||
})
|
||||
except git.GitCommandError as e:
|
||||
if "unknown revision or path" in str(e):
|
||||
commits = []
|
||||
else:
|
||||
raise
|
||||
return web.json_response({
|
||||
"repository": repo_name,
|
||||
"branch": branch,
|
||||
"commits": commits
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting commit log for {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error getting commit log: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def file_content(self, request):
|
||||
username = request['username']
|
||||
repo_name = request.match_info['repo_name']
|
||||
file_path = request.match_info.get('file_path', '')
|
||||
branch = request.query.get('branch', 'main')
|
||||
repository_path = request['repository_path']
|
||||
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||
if error_response:
|
||||
return error_response
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)
|
||||
try:
|
||||
temp_repo.git.checkout(branch)
|
||||
except git.GitCommandError:
|
||||
return web.Response(text=f"Branch '{branch}' not found", status=404)
|
||||
file_full_path = os.path.join(temp_dir, file_path)
|
||||
if not os.path.exists(file_full_path):
|
||||
return web.Response(text=f"File '{file_path}' not found", status=404)
|
||||
if os.path.isdir(file_full_path):
|
||||
files = os.listdir(file_full_path)
|
||||
return web.json_response({
|
||||
"repository": repo_name,
|
||||
"path": file_path,
|
||||
"type": "directory",
|
||||
"contents": files
|
||||
})
|
||||
else:
|
||||
try:
|
||||
with open(file_full_path, 'r') as f:
|
||||
content = f.read()
|
||||
return web.Response(text=content)
|
||||
except UnicodeDecodeError:
|
||||
return web.Response(text=f"Cannot display binary file content for '{file_path}'", status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting file content from {repo_name}: {str(e)}")
|
||||
return web.Response(text=f"Error getting file content: {str(e)}", status=500)
|
||||
|
||||
@require_auth
|
||||
async def git_smart_http(self, request):
|
||||
username = request['username']
|
||||
repository_path = request['repository_path']
|
||||
path = request.path
|
||||
async def get_repository_path():
|
||||
req_path = path.lstrip('/')
|
||||
if req_path.endswith('/info/refs'):
|
||||
repo_name = req_path[:-len('/info/refs')]
|
||||
elif req_path.endswith('/git-upload-pack'):
|
||||
repo_name = req_path[:-len('/git-upload-pack')]
|
||||
elif req_path.endswith('/git-receive-pack'):
|
||||
repo_name = req_path[:-len('/git-receive-pack')]
|
||||
else:
|
||||
repo_name = req_path
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
repo_name = repo_name.lstrip('git/')
|
||||
repo_dir = repository_path.joinpath(repo_name + '.git')
|
||||
logger.info(f"Resolved repo path: {repo_dir}")
|
||||
return repo_dir
|
||||
async def handle_info_refs(service):
|
||||
repo_path = await get_repository_path()
|
||||
|
||||
logger.info(f"handle_info_refs: {repo_path}")
|
||||
if not os.path.exists(repo_path):
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
logger.error(f"Git command failed: {stderr.decode()}")
|
||||
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason='OK',
|
||||
headers={
|
||||
'Content-Type': f'application/x-{service}-advertisement',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
)
|
||||
await response.prepare(request)
|
||||
packet = f"# service={service}\n"
|
||||
length = len(packet) + 4
|
||||
header = f"{length:04x}"
|
||||
await response.write(f"{header}{packet}0000".encode())
|
||||
await response.write(stdout)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling info/refs: {str(e)}")
|
||||
return web.Response(text=f"Server error: {str(e)}", status=500)
|
||||
async def handle_service_rpc(service):
|
||||
repo_path = await get_repository_path()
|
||||
logger.info(f"handle_service_rpc: {repo_path}")
|
||||
if not os.path.exists(repo_path):
|
||||
return web.Response(text="Repository not found", status=404)
|
||||
if not request.headers.get('Content-Type') == f'application/x-{service}-request':
|
||||
return web.Response(text="Invalid Content-Type", status=403)
|
||||
body = await request.read()
|
||||
cmd = [service, '--stateless-rpc', str(repo_path)]
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate(input=body)
|
||||
if process.returncode != 0:
|
||||
logger.error(f"Git command failed: {stderr.decode()}")
|
||||
return web.Response(text=f"Git error: {stderr.decode()}", status=500)
|
||||
return web.Response(
|
||||
body=stdout,
|
||||
content_type=f'application/x-{service}-result'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling service RPC: {str(e)}")
|
||||
return web.Response(text=f"Server error: {str(e)}", status=500)
|
||||
if request.method == 'GET' and path.endswith('/info/refs'):
|
||||
service = request.query.get('service')
|
||||
if service in ('git-upload-pack', 'git-receive-pack'):
|
||||
return await handle_info_refs(service)
|
||||
else:
|
||||
return web.Response(text="Smart HTTP requires service parameter", status=400)
|
||||
elif request.method == 'POST' and '/git-upload-pack' in path:
|
||||
return await handle_service_rpc('git-upload-pack')
|
||||
elif request.method == 'POST' and '/git-receive-pack' in path:
|
||||
return await handle_service_rpc('git-receive-pack')
|
||||
return web.Response(text="Not found", status=404)
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
import uvloop
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
logger.info("Using uvloop for improved performance")
|
||||
except ImportError:
|
||||
logger.info("uvloop not available, using standard event loop")
|
||||
app = GitApplication()
|
||||
logger.info("Starting Git server on port 8080")
|
||||
web.run_app(app, port=8080)
|
||||
59
src/snek/templates/settings/repositories/create.html
Normal file
59
src/snek/templates/settings/repositories/create.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% extends 'settings/index.html' %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-plus"></i> Create Repository</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<style>
|
||||
.container {
|
||||
div,input,label,button{
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
}
|
||||
form {
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}
|
||||
input[type="text"] {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc; border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
|
||||
button, a.button {
|
||||
background: #198754; color: #fff; border: none; border-radius: 5px;
|
||||
padding: 0.1rem 0.8rem; text-decoration: none; cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.
|
||||
.cancel {
|
||||
background: #6c757d;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.container { max-width: 98vw; }
|
||||
form { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<form action="/settings/repositories/create.html" method="post">
|
||||
<div>
|
||||
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Repository name">
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" value="1">
|
||||
<i class="fa-solid fa-lock"></i> Private
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"><i class="fa-solid fa-plus"></i> Create</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i> Back</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
63
src/snek/templates/settings/repositories/delete.html
Normal file
63
src/snek/templates/settings/repositories/delete.html
Normal file
@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Delete Repository</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
.container { max-width: 400px; margin: 0 auto; }
|
||||
.confirm-box {
|
||||
background: #ffe5e8;
|
||||
border: 1.5px solid #dc3545;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.repo-name {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
margin: 1rem 0;
|
||||
color: #dc3545;
|
||||
}
|
||||
.actions {
|
||||
display: flex; gap: 1rem; justify-content: center; margin-top: 1.5rem;
|
||||
}
|
||||
button, a {
|
||||
background: #dc3545; color: #fff;
|
||||
border: none; border-radius: 5px; padding: 0.6rem 1.2rem;
|
||||
font-size: 1rem; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 0.5rem; text-decoration: none; justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.cancel {
|
||||
background: #6c757d;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.container { max-width: 98vw; }
|
||||
.confirm-box { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><i class="fa-solid fa-trash-can"></i> Delete Repository</h1>
|
||||
<div class="confirm-box">
|
||||
<div>
|
||||
<i class="fa-solid fa-triangle-exclamation" style="font-size:2rem; color:#dc3545;"></i>
|
||||
</div>
|
||||
<p>Are you sure you want to <strong>delete</strong> the following repository?</p>
|
||||
<div class="repo-name"><i class="fa-solid fa-book"></i> my-first-repo</div>
|
||||
<form action="/repositories/delete" method="post" style="margin-top:1.5rem;">
|
||||
<input type="hidden" name="id" value="1">
|
||||
<div class="actions">
|
||||
<button type="submit"><i class="fa-solid fa-trash"></i> Yes, delete</button>
|
||||
<a href="repositories.html" class="cancel"><i class="fa-solid fa-ban"></i> Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
106
src/snek/templates/settings/repositories/index.html
Normal file
106
src/snek/templates/settings/repositories/index.html
Normal file
@ -0,0 +1,106 @@
|
||||
{% extends 'settings/index.html' %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-database"></i> Repositories</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Repositories - List</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<style>
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.repo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repo-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
.repo-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.repo-row { flex-direction: column; align-items: stretch; }
|
||||
.actions { justify-content: flex-start; }
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
/* justify-content: flex-end;*/
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
button, a.button {
|
||||
background: #198754; color: #fff; border: none; border-radius: 5px;
|
||||
padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.button.delete { background: #dc3545; }
|
||||
.button.edit { background: #0d6efd; }
|
||||
.button.clone { background: #6c757d; }
|
||||
.button.browse { background: #ffc107; color: #212529; }
|
||||
.button.create { background: #20c997; margin-left: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<div class="topbar">
|
||||
<a class="button create" href="/settings/repositories/create.html">
|
||||
<i class="fa-solid fa-plus"></i> New Repository
|
||||
</a>
|
||||
</div>
|
||||
<section class="repo-list">
|
||||
<!-- Example repository entries; replace with your templating/iteration -->
|
||||
{% for repo in repositories %}
|
||||
<div class="repo-row">
|
||||
<div class="repo-info">
|
||||
<span class="repo-name"><i class="fa-solid fa-book"></i> {{ repo.name }}</span>
|
||||
|
||||
<span title="Public">
|
||||
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
|
||||
{% if repo.is_private %}Private{% else %}Public{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button browse" href="/repositories/{{ user.username }}/{{ repo.name }}" target="_blank">
|
||||
<i class="fa-solid fa-folder-open"></i> Browse
|
||||
</a>
|
||||
<a class="button clone" href="/repositories/{{ user.username }}/{{ repo.name }}/clone">
|
||||
<i class="fa-solid fa-code-branch"></i> Clone
|
||||
</a>
|
||||
<a class="button edit" href="/settings/repositories/repository/{{ repo.name }}/update.html">
|
||||
<i class="fa-solid fa-pen"></i> Edit
|
||||
</a>
|
||||
<a class="button delete" href="/settings/repositories/{{ repo.name }}/delete.html">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<!-- ... -->
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
45
src/snek/templates/settings/repositories/update.html
Normal file
45
src/snek/templates/settings/repositories/update.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% extends "settings/index.html" %}
|
||||
|
||||
{% block header_text %}<h1><i class="fa-solid fa-pen"></i> Update Repository</h1>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<style>
|
||||
form {
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}
|
||||
button {
|
||||
background: #0d6efd; color: #fff;
|
||||
border: none; border-radius: 5px; padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.cancel {
|
||||
background: #6c757d;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.container { max-width: 98vw; }
|
||||
form { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<form method="post">
|
||||
<!-- Assume hidden id for backend use -->
|
||||
<input type="hidden" name="id" value="{{ repository.id }}">
|
||||
<div>
|
||||
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ repository.name }}" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" value="1" {% if repository.is_private %}checked{% endif %}>
|
||||
<i class="fa-solid fa-lock"></i> Private
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -3,7 +3,7 @@
|
||||
<h2>You</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/settings/profile.html">Profile</a></li>
|
||||
<li><a class="no-select" href="/settings/gists.html">Gists</a></li>
|
||||
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
|
||||
</ul>
|
||||
|
||||
</aside>
|
||||
|
||||
68
src/snek/view/settings/repositories.py
Normal file
68
src/snek/view/settings/repositories.py
Normal file
@ -0,0 +1,68 @@
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseFormView
|
||||
import pathlib
|
||||
|
||||
class RepositoriesIndexView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
user_uid = self.session.get("uid")
|
||||
|
||||
repositories = []
|
||||
async for repository in self.services.repository.find(user_uid=user_uid):
|
||||
repositories.append(repository.record)
|
||||
|
||||
return await self.render_template("settings/repositories/index.html", {"repositories": repositories})
|
||||
|
||||
|
||||
|
||||
|
||||
class RepositoriesCreateView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
return await self.render_template("settings/repositories/create.html")
|
||||
|
||||
async def post(self):
|
||||
data = await self.request.post()
|
||||
repository = await self.services.repository.create(user_uid=self.session.get("uid"), name=data['name'], is_private=int(data.get('is_private',0)))
|
||||
return web.HTTPFound("/settings/repositories/index.html")
|
||||
|
||||
class RepositoriesUpdateView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
repository = await self.services.repository.get(
|
||||
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
||||
)
|
||||
if not repository:
|
||||
return web.HTTPNotFound()
|
||||
return await self.render_template("settings/repositories/update.html", {"repository": repository.record})
|
||||
|
||||
async def post(self):
|
||||
data = await self.request.post()
|
||||
repository = await self.services.repository.get(
|
||||
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
||||
)
|
||||
repository['is_private'] = int(data.get('is_private',0))
|
||||
await self.services.repository.save(repository)
|
||||
return web.HTTPFound("/settings/repositories/index.html")
|
||||
|
||||
class RepositoriesDeleteView(BaseFormView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def get(self):
|
||||
|
||||
return await self.render_template("settings/repositories/delete.html")
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user