roundabout,
created on Friday, 3 November 2023, 14:17:52 (1699021072),
received on Wednesday, 31 July 2024, 06:54:38 (1722408878)
Author identity: vlad <vlad.muntoiu@gmail.com>
748a0e9dfde00a93d3ae0f24caa4c368b75987bc
config.py
@@ -1,8 +1,16 @@
import os
from dotenv import load_dotenv
load_dotenv("secrets.env")
DB_PASSWORD = os.environ.get("DB_PASSWORD")
REPOS_PATH = "./repos"
BASE_DOMAIN = "localhost"
HASHING_ROUNDS = 16
RESERVED_NAMES = ("git", "settings", "logout", "accounts",)
locking = False
folderIcon = "mdi:folder"
unknownIcon = "mdi:file"
@@ -102,4 +110,3 @@ def matchIcon(name):
return "mdi:book-information-variant"
if name.startswith(".gitignore"):
return "simple-icons:git"
gitHTTP.py
@@ -1,31 +1,134 @@
from gitme import app
import uuid
from gitme import app, User, db, bcrypt
import os
import shutil
import config
import flask
import git
import subprocess
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
@auth.verify_password
def verifyPassword(username, password):
user = User.query.filter_by(username=username).first()
if user and bcrypt.check_password_hash(user.passwordHashed, password):
flask.session["username"] = user.username
flask.g.user = username
return True
return False
@app.route("/git/<username>/<repository>/<path:subpath>", methods=["PROPFIND"])
def gitList(username, repository, subpath):
serverRepoLocation = os.path.join(config.REPOS_PATH, username, repository, ".git", subpath)
if not os.path.exists(serverRepoLocation):
flask.abort(404)
text = """<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:">
"""
for file in os.listdir(serverRepoLocation):
text += f"""
<D:response>
<D:href>/git/{username}/{repository}/</D:href>
"""
if os.path.isdir(file):
text += """
<D:propstat>
<D:prop>
<D:resourcetype>
<D:collection/>
</D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
"""
else:
text += """
<D:propstat>
<D:prop/>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
"""
text += """
</D:response>"""
text += """
</D:multistatus>"""
return text
@app.route("/git/<username>/<repository>/", methods=["GET", "POST"])
def gitRoot(username, repository):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
app.logger.info(f"Loading {serverRepoLocation}")
if not os.path.exists(serverRepoLocation):
app.logger.error(f"Cannot load {serverRepoLocation}")
flask.abort(404)
repo = git.Repo(serverRepoLocation)
@app.route("/git/<username>/<repository>/<path:git_path>", methods=["GET", "POST"])
def git_http_backend(username, repository, git_path):
repo_path = os.path.join(config.REPOS_PATH, username, repository, ".git")
repo = git.Repo(repo_path)
git_cmd = repo.git
@auth.login_required
def gitInfo(username, repository, git_path=""):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
app.logger.info(f"Loading {serverRepoLocation}")
if not os.path.exists(serverRepoLocation):
app.logger.error(f"Cannot load {serverRepoLocation}")
flask.abort(404)
repo = git.Repo(serverRepoLocation)
if flask.request.method == "GET":
result = git_cmd.upload_pack()
method = "upload-pack"
result = repo.git.upload_pack()
elif flask.request.method == "POST":
result = git_cmd.receive_pack()
method = "receive-pack"
if flask.request.headers.get("Content-Type") == "application/x-git-receive-pack-request":
result = repo.git.receive_pack()
else:
result = "Not supported"
response = flask.Response(result, content_type="application/x-git-" + git_cmd[-1])
response = flask.Response(result, content_type=f"application/x-git-{method}-result")
return response
@app.route("/git/<username>/<repository>/<path:git_path>", methods=["MKCOL"])
def gitDummyMkCol(username, repository, git_path):
return "", 200
@app.route("/git/<username>/<repository>/info/", methods=["MKCOL"])
def gitMakeInfo(username, repository):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
app.logger.info(f"Loading {serverRepoLocation}")
if not os.path.exists(serverRepoLocation):
app.logger.error(f"Cannot load {serverRepoLocation}")
flask.abort(404)
if not os.path.exists(os.path.join(serverRepoLocation, ".git", "info")):
os.makedirs(os.path.join(serverRepoLocation, ".git", "info"))
return "", 200
@app.route("/git/<username>/<repository>/info/refs")
def gitInfoRefs(username, repository):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
app.logger.info(f"Loading {serverRepoLocation}")
if not os.path.exists(serverRepoLocation):
@@ -39,27 +142,26 @@ def gitInfoRefs(username, repository):
app.logger.warning(text)
return flask.Response(text, content_type="application/x-git-upload-pack-result")
return flask.Response(text, content_type=f"application/x-{flask.request.args.get('service')}-result")
@app.route("/git/<username>/<repository>/objects/info/alternates", methods=["GET"])
@auth.login_required
def gitInfoAlternates(username, repository):
# Handle the request and provide the response
text = "#Alternate object stores\n"
return flask.Response(text, content_type="text/plain")
@app.route("/git/<username>/<repository>/objects/info/http-alternates")
@auth.login_required
def gitHttpAlternates(username, repository):
# Return an empty response
return flask.Response("", content_type="text/plain")
@app.route("/git/<username>/<repository>/HEAD")
@auth.login_required
def gitHead(username, repository):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
app.logger.info(f"Loading {serverRepoLocation}")
if not os.path.exists(serverRepoLocation):
@@ -67,62 +169,173 @@ def gitHead(username, repository):
flask.abort(404)
repo = git.Repo(serverRepoLocation)
text = f"ref: {repo.head.ref}\n"
return flask.Response(text, content_type="text/plain")
@app.route("/git/<username>/<repository>/objects/info/packs")
def gitInfoPacks(username, repository):
repo_path = os.path.join(config.REPOS_PATH, username, repository)
serverRepoLocation = os.path.join(config.REPOS_PATH, username, repository, ".git")
packs = [f for f in os.listdir(os.path.join(serverRepoLocation, "objects", "pack")) if f.endswith(".pack")]
text = "\n".join(packs) + "\n"
return flask.Response(text, content_type="text/plain")
# List the pack files in the repository
packs = [f for f in os.listdir(os.path.join(repo_path, "objects", "pack")) if f.endswith(".pack")]
@app.route("/git/<username>/<repository>/objects/<path:objectPath>")
@auth.login_required
def gitObject(username, repository, objectPath):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
objectFullPath = os.path.join(serverRepoLocation, ".git/objects", objectPath)
# Generate a response with the list of pack files
response_text = "\n".join(packs) + "\n"
if not os.path.exists(objectFullPath):
return flask.Response("Object not found", status=404, content_type="text/plain")
return flask.Response(response_text, content_type="text/plain")
with open(objectFullPath, "rb") as f:
objectData = f.read()
return flask.Response(objectData, content_type="application/octet-stream")
@app.route("/git/<username>/<repository>/objects/<path:object_path>")
def gitObject(username, repository, object_path):
repo_path = os.path.join(config.REPOS_PATH, username, repository)
object_full_path = os.path.join(repo_path, ".git/objects", object_path)
# Check if the object file exists
if not os.path.exists(object_full_path):
return flask.Response("Object not found", status=404, content_type="text/plain")
@app.route("/git/<username>/<repository>/<path:itemPath>", methods=["PUT"])
# @auth.login_required
def gitPut(username, repository, itemPath):
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
itemFullPath = os.path.join(serverRepoLocation, ".git", itemPath)
# Serve the object as binary data
with open(object_full_path, "rb") as object_file:
object_data = object_file.read()
if not os.path.exists(os.path.dirname(itemFullPath)):
os.makedirs(os.path.dirname(itemFullPath))
return flask.Response(object_data, content_type="application/octet-stream")
with open(itemFullPath, "wb") as f:
f.write(flask.request.data)
return "", 200
@app.route("/git/<username>/<repository>/<path:itemPath>", methods=["MOVE"])
# @auth.login_required
def gitMove(username, repository, itemPath):
serverRepoLocation = os.path.join(config.REPOS_PATH, username, repository)
itemFullPath = os.path.join(serverRepoLocation, ".git", itemPath)
newPath = os.path.join(serverRepoLocation, ".git", flask.request.headers["destination"].split("/", 6)[-1])
app.logger.info(f"move {itemPath} to {newPath}")
shutil.move(itemFullPath, newPath)
return "", 200
@app.route("/git/<username>/<repository>/git-upload-pack", methods=["POST"])
@auth.login_required
def gitUploadPack(username, repository):
repoPath = "/path/to/repos/{}/{}/".format(username, repository)
cmd = ["git", "--git-dir", repoPath, "upload-pack"]
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
cmd = ["git", "--git-dir", serverRepoLocation, "upload-pack"]
gitProcess = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Forward the Git process input and output
inputData = flask.request.get_data()
output = gitProcess.communicate(input=inputData)
return flask.Response(output[0], content_type="application/x-git-upload-pack-result")
@app.route("/git/<username>/<repository>/git-receive-pack", methods=["POST"])
@auth.login_required
def gitReceivePack(username, repository):
repoPath = "/path/to/repos/{}/{}/".format(username, repository)
cmd = ["git", "--git-dir", repoPath, "receive-pack"]
serverRepoLocation = os.path.join(config.REPOS_PATH, username, repository)
gitProcess = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
proc = subprocess.Popen(['git', '--work-tree', serverRepoLocation, 'receive-pack'], stdin=subprocess.PIPE,
stdout=subprocess.PIPE)
proc.stdin.write(flask.request.get_data())
proc.stdin.close()
response = proc.stdout.read()
# Forward the Git process input and output
inputData = flask.request.get_data()
output = gitProcess.communicate(input=inputData)
return flask.Response(output[0], content_type="application/x-git-receive-pack-result")
return flask.Response(response, content_type='application/x-git-receive-pack-result')
@app.route("/git/<username>/<repository>/", methods=["PROPFIND"])
def gitPropFind(username, repository):
serverRepoLocation = os.path.join(config.REPOS_PATH, username, repository)
if not os.path.exists(serverRepoLocation):
flask.abort(404)
repo = git.Repo(serverRepoLocation)
branches = ""
for branch in repo.heads:
branches += f"""
<ns0:branch xmlns="DAV:">
<ns0:name>{branch.name}</ns0:name>
</ns0:branch>
"""
response = f"""<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/git/{username}/{repository}/</D:href>
<D:propstat>
<D:status>HTTP/1.1 200 OK</D:status>
<D:prop>
<D:displayname>{repository}</D:displayname>
<D:resourcetype><D:collection/></D:resourcetype>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
</D:prop>
</D:propstat>
</D:response>
</D:multistatus>
"""
return flask.Response(response, content_type="application/xml")
locks = {}
@app.route("/git/<username>/<repository>/<path:subpath>", methods=["LOCK", "UNLOCK"])
def gitLock(username, repository, subpath):
if flask.request.method == "LOCK":
scope = flask.request.args.get("lockscope")
type_ = flask.request.args.get("locktype")
if username + "/" + repository in locks:
return "Lock already exists", 423
lockToken = uuid.uuid4().hex
if config.locking:
locks[username + "/" + repository] = {
"scope": scope,
"type": type_,
"token": lockToken
}
text = f"""<?xml version="1.0" encoding="UTF-8"?>
<D:prop>
<D:lockdiscovery>
<D:activelock>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:depth>Infinity</D:depth>
<D:owner>
<D:href>https://{config.BASE_DOMAIN}/{username}</D:href>
</D:owner>
<D:timeout>Second-600</D:timeout>
<D:locktoken>
<D:href>opaquelocktoken:{lockToken}</D:href>
</D:locktoken>
</D:activelock>
</D:lockdiscovery>
</D:prop>
"""
return text
elif flask.request.method == "UNLOCK":
lockToken = flask.request.headers.get("Lock-Token")
if config.locking and locks[username + "/" + repository]["token"] == lockToken:
del locks[username + "/" + repository]
return ""
gitme.py
@@ -1,16 +1,49 @@
import os
import flask
from flask_sqlalchemy import SQLAlchemy
import git
import mimetypes
import magic
from markupsafe import escape
from flask_bcrypt import Bcrypt
from markupsafe import escape, Markup
from flask_migrate import Migrate
import config
app = flask.Flask(__name__)
import gitHTTP
mime = magic.Magic(mime=True)
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()
app.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql://root:{config.DB_PASSWORD}@localhost/gitme"
app.config["SECRET_KEY"] = config.DB_PASSWORD
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
bcrypt = Bcrypt(app)
migrate = Migrate(app, db)
def onlyChars(string, chars):
for i in string:
if i not in chars:
return False
return True
with app.app_context():
class User(db.Model):
username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True)
displayName = db.Column(db.String(128), unique=False, nullable=True)
passwordHashed = db.Column(db.String(60), nullable=False)
email = db.Column(db.String(254), nullable=True)
def __init__(self, username, password, email=None, displayName=None):
self.username = username
self.passwordHashed = bcrypt.generate_password_hash(password, config.HASHING_ROUNDS).decode("utf-8")
self.email = email
import gitHTTP
def humanSize(value, decimals=2, scale=1024, units=("B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")):
@@ -27,8 +60,8 @@ def humanSize(value, decimals=2, scale=1024, units=("B", "kiB", "MiB", "GiB", "T
def guessMIME(path):
if os.path.isdir(path):
mimetype = "inode/directory"
elif mime.from_file(path):
mimetype = mime.from_file(path)
elif magic.from_file(path, mime=True):
mimetype = magic.from_file(path, mime=True)
else:
mimetype = "application/octet-stream"
return mimetype
@@ -40,14 +73,83 @@ def convertToHTML(path):
return contents
@app.context_processor
def default():
username = flask.session.get("username")
return {"loggedInUser": username}
@app.route("/")
def main():
return flask.render_template("home.html", title="gitme")
@app.route("/settings/")
def main():
return flask.render_template("user-settings.html", title="gitme")
@app.route("/accounts/", methods=["GET", "POST"])
def login():
return flask.render_template("login.html", title="gitme")
if flask.request.method == "GET":
return flask.render_template("login.html", title="gitme")
else:
if "login" in flask.request.form:
username = flask.request.form["username"]
password = flask.request.form["password"]
user = User.query.filter_by(username=username).first()
if user and bcrypt.check_password_hash(user.passwordHashed, password):
flask.session["username"] = user.username
flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged in as {username}"), category="success")
return flask.redirect("/", code=303)
elif not user:
flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>User not found"), category="alert")
return flask.render_template("login.html", title="gitme")
else:
flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>Invalid password"), category="error")
return flask.render_template("login.html", title="gitme")
if "signup" in flask.request.form:
username = flask.request.form["username"]
password = flask.request.form["password"]
password2 = flask.request.form["password2"]
email = flask.request.form.get("email")
email2 = flask.request.form.get("email2") # repeat email is a honeypot
name = flask.request.form.get("name")
if not onlyChars(username, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"):
flask.flash(Markup("<iconify-icon icon='mdi:account-error'></iconify-icon>Usernames may only contain Latin alphabet, numbers, '-' and '_'"), category="error")
return flask.render_template("login.html", title="gitme")
if username in config.RESERVED_NAMES:
flask.flash(Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>Sorry, {username} is a system path"), category="error")
return flask.render_template("login.html", title="gitme")
userCheck = User.query.filter_by(username=username).first()
if userCheck:
flask.flash(Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>The username {username} is taken"), category="error")
return flask.render_template("login.html", title="gitme")
if password2 != password:
flask.flash(Markup("<iconify-icon icon='mdi:key-alert'></iconify-icon>Make sure the passwords match"), category="error")
return flask.render_template("login.html", title="gitme")
user = User(username, password, email, name)
os.makedirs(os.path.join(config.REPOS_PATH, username))
db.session.add(user)
db.session.commit()
flask.session["username"] = user.username
flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully created and logged in as {username}"), category="success")
return flask.redirect("/", code=303)
@app.route("/logout")
def logout():
flask.session.clear()
flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged out"), category="info")
return flask.redirect("/", code=303)
@app.route("/<username>/")
@@ -163,8 +265,8 @@ def repositoryTree(username, repository, branch, subpath):
icon = specialIcon
elif os.path.isdir(path):
icon = config.folderIcon
elif mimetypes.guess_type(path)[0] in config.fileIcons:
icon = config.fileIcons[mimetypes.guess_type(path)[0]]
elif guessMIME(path)[0] in config.fileIcons:
icon = config.fileIcons[guessMIME(path)[0]]
else:
icon = config.unknownIcon
@@ -181,6 +283,7 @@ def repositoryTree(username, repository, branch, subpath):
current=branch,
mode=mode,
mimetype=mimetype,
detailedtype=magic.from_file(path),
size=size,
icon=icon,
subpath=os.path.join("/", subpath),
post-receive
@@ -0,0 +1,16 @@
#!/bin/bash
while read oldrev newrev refname
do
# Check if the refname starts with "refs/heads/"
if [[ $refname == refs/heads/* ]]; then
# Extract the branch name from refname
branchname="${refname#refs/heads/}"
# Change to the repository's working tree
cd /path/to/your/repo/working/tree
# Update the working tree for the branch
git checkout -f "$branchname"
fi
done
static/style.css
@@ -82,7 +82,7 @@ button, input, .button, select {
text-decoration: none;
}
#repo-tabs, #global-nav > ul {
#repo-tabs, #global-nav > ul, #home-tabs {
padding: 0;
margin: 0;
}
@@ -94,4 +94,12 @@ button, input, .button, select {
/* for now */
#repo-table > * > tr > :is(td, th):nth-child(1) {
display: none;
}
.password-error {
background: var(--color-error);
color: var(--color-error-text);
padding: 8px;
gap: 4px;
border-radius: 2px;
}
templates/default.html
@@ -24,8 +24,13 @@
{% block breadcrumbs %}{% endblock %}
</ul>
<x-hbox style="--gap: 2ch;">
<a href="/accounts">Log in or sign up</a>
<a href="/settings">Preferences</a>
{% if loggedInUser %}
<a href="/{{ loggedInUser }}">{{ loggedInUser }}</a>
<a href="/logout">Logout</a>
<a href="/settings">Preferences</a>
{% else %}
<a href="/accounts">Log in or sign up</a>
{% endif %}
</x-hbox>
</nav>
{% block nav %}{% endblock %}
@@ -33,6 +38,33 @@
<main>
{% block content %}{% endblock %}
</main>
{% with messages = get_flashed_messages(with_categories=true) %}
<ol class="toast-container">
{% for category, message in messages %}
<li style="
{% if category %}
background-color:
{% if category == 'error' %}var(--color-error)
{% elif category == 'alert' %}var(--color-alert)
{% elif category == 'info' %}var(--color-info)
{% elif category == 'success' %}var(--color-success)
{% endif %};
color:
{% if category == 'error' %}var(--color-error-text)
{% elif category == 'alert' %}var(--color-alert-text)
{% elif category == 'info' %}var(--color-info-text)
{% elif category == 'success' %}var(--color-success-text)
{% endif %};
{% endif %}"
>
{{ message }}
<x-buttonbox>
<button class="button-flat" onclick="removeToast()" style="color: inherit !important;">Close</button>
</x-buttonbox>
</li>
{% endfor %}
</ol>
{% endwith %}
</body>
<script src="/static/ripples.js"></script>
<script src="/static/efficient-ui/dialogs.js"></script>
templates/file-view.html
@@ -7,6 +7,8 @@
</x-buttonbox>
</x-hbox>
{{ mimetype }} • {{ size[0] }} {{ size[1] }}
<br>
{{ detailedtype }}
{% if mode == "text" %}
<pre>{{ contents }}</pre>
templates/login.html
@@ -12,17 +12,18 @@
<x-notebook>
<label><input type="radio" name="login-tabs" checked>Log in</label>
<x-tab>
<form>
<form method="post" id="login">
<input type="hidden" name="login" value="login">
<div class="card">
<section>
<x-vbox>
<x-vbox class="nopad">
<label for="login-username">Username</label>
<input id="login-username" required>
<input id="login-username" name="username" required>
</x-vbox>
<x-vbox class="nopad">
<label for="login-password">Password</label>
<input id="login-password" type="password" required>
<input id="login-password" name="password" type="password" required>
</x-vbox>
<button style="width: 100%;" id="login-submit" type="submit">Log in</button>
</x-vbox>
@@ -73,36 +74,37 @@
</x-tab>
<label><input type="radio" name="login-tabs">Sign up</label>
<x-tab>
<form>
<form method="post" id="signup">
<input type="hidden" name="signup" value="signup">
<div class="card">
<section>
<x-vbox>
<x-vbox class="nopad">
<label for="signup-username">Wanted username</label>
<input id="signup-username" required>
<input id="signup-username" name="username" required>
</x-vbox>
<x-vbox class="nopad">
<label for="signup-password">Password</label>
<input id="signup-password" type="password" required>
<input id="signup-password" name="password" type="password" required>
</x-vbox>
<x-vbox class="nopad">
<label for="signup-password-2">Repeat password</label>
<input id="signup-password-2" type="password" required>
<input id="signup-password-2" name="password2" type="password" required>
</x-vbox>
<x-vbox class="nopad">
<label for="signup-email">Email (recommended)</label>
<input id="signup-email" type="email">
<input id="signup-email" name="email" type="email">
</x-vbox>
<x-vbox class="nopad" style="display: none;">
<label for="signup-email-2">Repeat email (recommended)</label>
<input id="signup-email-2" type="email">
<input id="signup-email-2" name="email2" type="email">
</x-vbox>
<x-vbox class="nopad">
<label for="signup-name">Friendly name (optional)</label>
<input id="signup-name">
<input id="signup-name" name="name">
</x-vbox>
<label>
<input id="signup-terms" type="checkbox" required>
<input id="signup-terms" type="checkbox" name="tos" required>
I accept the <a href="/help/policies">policies listed here</a>
</label>
<button style="width: 100%;" id="signup-submit" type="submit">Sign up</button>