By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 app.py

View raw Download
text/x-script.python • 23.83 kiB
Python script, ASCII text executable
        
            
1
import os
2
import random
3
import subprocess
4
from functools import wraps
5
6
import cairosvg
7
import flask
8
from flask_sqlalchemy import SQLAlchemy
9
import git
10
import mimetypes
11
import magic
12
from flask_bcrypt import Bcrypt
13
from markupsafe import escape, Markup
14
from flask_migrate import Migrate
15
from datetime import datetime
16
from enum import Enum
17
import shutil
18
from PIL import Image
19
from cairosvg import svg2png
20
import platform
21
22
import config
23
24
app = flask.Flask(__name__)
25
26
from flask_httpauth import HTTPBasicAuth
27
28
auth = HTTPBasicAuth()
29
30
app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI
31
app.config["SECRET_KEY"] = config.DB_PASSWORD
32
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
33
db = SQLAlchemy(app)
34
bcrypt = Bcrypt(app)
35
migrate = Migrate(app, db)
36
37
38
def gitCommand(repo, data, *args):
39
if not os.path.isdir(repo):
40
raise FileNotFoundError("Repo not found")
41
env = os.environ.copy()
42
43
command = ["git", *args]
44
45
proc = subprocess.Popen(" ".join(command), cwd=repo, env=env, shell=True, stdout=subprocess.PIPE,
46
stdin=subprocess.PIPE)
47
print(command)
48
49
if data:
50
proc.stdin.write(data)
51
52
out, err = proc.communicate()
53
return out
54
55
56
def onlyChars(string, chars):
57
for i in string:
58
if i not in chars:
59
return False
60
return True
61
62
63
with app.app_context():
64
class RepoAccess(db.Model):
65
id = db.Column(db.Integer, primary_key=True)
66
userUsername = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False)
67
repoRoute = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False)
68
accessLevel = db.Column(db.SmallInteger(), nullable=False) # 0 read-only, 1 read-write, 2 admin
69
70
user = db.relationship("User", back_populates="repoAccess")
71
repo = db.relationship("Repo", back_populates="repoAccess")
72
73
__table_args__ = (db.UniqueConstraint("userUsername", "repoRoute", name="_user_repo_uc"),)
74
75
def __init__(self, user, repo, level):
76
self.userUsername = user.username
77
self.repoRoute = repo.route
78
self.accessLevel = level
79
80
81
class User(db.Model):
82
username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True)
83
displayName = db.Column(db.Unicode(128), unique=False, nullable=True)
84
bio = db.Column(db.Unicode(512), unique=False, nullable=True)
85
passwordHashed = db.Column(db.String(60), nullable=False)
86
email = db.Column(db.String(254), nullable=True)
87
company = db.Column(db.Unicode(64), nullable=True)
88
companyURL = db.Column(db.String(256), nullable=True)
89
URL = db.Column(db.String(256), nullable=True)
90
showMail = db.Column(db.Boolean, default=False, nullable=False)
91
location = db.Column(db.Unicode(64), nullable=True)
92
creationDate = db.Column(db.DateTime, default=datetime.utcnow)
93
94
repositories = db.relationship("Repo", back_populates="owner")
95
repoAccess = db.relationship("RepoAccess", back_populates="user")
96
97
def __init__(self, username, password, email=None, displayName=None):
98
self.username = username
99
self.passwordHashed = bcrypt.generate_password_hash(password, config.HASHING_ROUNDS).decode("utf-8")
100
self.email = email
101
self.displayName = displayName
102
103
# Create the user's directory
104
if not os.path.exists(os.path.join(config.REPOS_PATH, username)):
105
os.makedirs(os.path.join(config.REPOS_PATH, username))
106
if not os.path.exists(os.path.join(config.USERDATA_PATH, username)):
107
os.makedirs(os.path.join(config.USERDATA_PATH, username))
108
109
avatarName = random.choice(os.listdir(config.DEFAULT_AVATARS_PATH))
110
if os.path.join(config.DEFAULT_AVATARS_PATH, avatarName).endswith(".svg"):
111
cairosvg.svg2png(url=os.path.join(config.DEFAULT_AVATARS_PATH, avatarName),
112
write_to="/tmp/roundabout-avatar.png")
113
avatar = Image.open("/tmp/roundabout-avatar.png")
114
else:
115
avatar = Image.open(os.path.join(config.DEFAULT_AVATARS_PATH, avatarName))
116
avatar.thumbnail(config.AVATAR_SIZE)
117
avatar.save(os.path.join(config.USERDATA_PATH, username, "avatar.png"))
118
119
120
class Repo(db.Model):
121
route = db.Column(db.String(98), unique=True, nullable=False, primary_key=True)
122
ownerName = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False)
123
name = db.Column(db.String(64), nullable=False)
124
owner = db.relationship("User", back_populates="repositories")
125
visibility = db.Column(db.SmallInteger(), nullable=False)
126
info = db.Column(db.Unicode(512), nullable=True)
127
URL = db.Column(db.String(256), nullable=True)
128
creationDate = db.Column(db.DateTime, default=datetime.utcnow)
129
130
defaultBranch = db.Column(db.String(64), nullable=True, default="")
131
132
commits = db.relationship("Commit", back_populates="repo")
133
repoAccess = db.relationship("RepoAccess", back_populates="repo")
134
135
def __init__(self, owner, name, visibility):
136
self.route = f"/{owner.username}/{name}"
137
self.name = name
138
self.ownerName = owner.username
139
self.owner = owner
140
self.visibility = visibility
141
142
# Add the owner as an admin
143
repoAccess = RepoAccess(owner, self, 2)
144
db.session.add(repoAccess)
145
146
147
class Commit(db.Model):
148
identifier = db.Column(db.String(227), unique=True, nullable=False, primary_key=True)
149
sha = db.Column(db.String(128), nullable=False)
150
repoName = db.Column(db.String(97), db.ForeignKey("repo.route"), nullable=False)
151
ownerName = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False)
152
ownerIdentity = db.Column(db.String(321))
153
receiveDate = db.Column(db.DateTime, default=datetime.utcnow)
154
authorDate = db.Column(db.DateTime)
155
message = db.Column(db.UnicodeText)
156
repo = db.relationship("Repo", back_populates="commits")
157
158
def __init__(self, sha, owner, repo, date, message, ownerIdentity):
159
self.identifier = f"/{owner.username}/{repo.name}/{sha}"
160
self.sha = sha
161
self.repoName = repo.route
162
self.repo = repo
163
self.ownerName = owner.username
164
self.owner = owner
165
self.authorDate = datetime.fromtimestamp(int(date))
166
self.message = message
167
self.ownerIdentity = ownerIdentity
168
169
170
def getPermissionLevel(loggedIn, username, repository):
171
user = User.query.filter_by(username=loggedIn).first()
172
repo = Repo.query.filter_by(route=f"/{username}/{repository}").first()
173
174
if user and repo:
175
permission = RepoAccess.query.filter_by(user=user, repo=repo).first()
176
if permission:
177
return permission.accessLevel
178
179
return None
180
181
182
def getVisibility(username, repository):
183
repo = Repo.query.filter_by(route=f"/{username}/{repository}").first()
184
185
if repo:
186
return repo.visibility
187
188
return None
189
190
191
import gitHTTP
192
193
194
def humanSize(value, decimals=2, scale=1024,
195
units=("B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")):
196
for unit in units:
197
if value < scale:
198
break
199
value /= scale
200
if int(value) == value:
201
# do not return decimals, if the value is already round
202
return int(value), unit
203
return round(value * 10 ** decimals) / 10 ** decimals, unit
204
205
206
def guessMIME(path):
207
if os.path.isdir(path):
208
mimetype = "inode/directory"
209
elif magic.from_file(path, mime=True):
210
mimetype = magic.from_file(path, mime=True)
211
else:
212
mimetype = "application/octet-stream"
213
return mimetype
214
215
216
def convertToHTML(path):
217
with open(path, "r") as f:
218
contents = f.read()
219
return contents
220
221
222
@app.context_processor
223
def default():
224
username = flask.session.get("username")
225
226
return {"loggedInUser": username}
227
228
229
@app.route("/")
230
def main():
231
return flask.render_template("home.html")
232
233
234
@app.route("/about/")
235
def about():
236
return flask.render_template("about.html", platform=platform)
237
238
239
@app.route("/settings/", methods=["GET", "POST"])
240
def settings():
241
if flask.request.method == "GET":
242
if not flask.session.get("username"):
243
flask.abort(401)
244
user = User.query.filter_by(username=flask.session.get("username")).first()
245
246
return flask.render_template("user-settings.html", user=user)
247
else:
248
user = User.query.filter_by(username=flask.session.get("username")).first()
249
250
user.displayName = flask.request.form["displayname"]
251
user.URL = flask.request.form["url"]
252
user.company = flask.request.form["company"]
253
user.companyURL = flask.request.form["companyurl"]
254
user.location = flask.request.form["location"]
255
user.showMail = flask.request.form.get("showmail", user.showMail)
256
257
db.session.commit()
258
259
flask.flash(Markup("<iconify-icon icon='mdi:check'></iconify-icon>Settings saved"), category="success")
260
return flask.redirect(f"/{flask.session.get('username')}", code=303)
261
262
263
@app.route("/accounts/", methods=["GET", "POST"])
264
def login():
265
if flask.request.method == "GET":
266
return flask.render_template("login.html")
267
else:
268
if "login" in flask.request.form:
269
username = flask.request.form["username"]
270
password = flask.request.form["password"]
271
272
user = User.query.filter_by(username=username).first()
273
274
if user and bcrypt.check_password_hash(user.passwordHashed, password):
275
flask.session["username"] = user.username
276
flask.flash(
277
Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged in as {username}"),
278
category="success")
279
return flask.redirect("/", code=303)
280
elif not user:
281
flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>User not found"),
282
category="alert")
283
return flask.render_template("login.html")
284
else:
285
flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>Invalid password"),
286
category="error")
287
return flask.render_template("login.html")
288
if "signup" in flask.request.form:
289
username = flask.request.form["username"]
290
password = flask.request.form["password"]
291
password2 = flask.request.form["password2"]
292
email = flask.request.form.get("email")
293
email2 = flask.request.form.get("email2") # repeat email is a honeypot
294
name = flask.request.form.get("name")
295
296
if not onlyChars(username, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"):
297
flask.flash(Markup(
298
"<iconify-icon icon='mdi:account-error'></iconify-icon>Usernames may only contain Latin alphabet, numbers, '-' and '_'"),
299
category="error")
300
return flask.render_template("login.html")
301
302
if username in config.RESERVED_NAMES:
303
flask.flash(
304
Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>Sorry, {username} is a system path"),
305
category="error")
306
return flask.render_template("login.html")
307
308
userCheck = User.query.filter_by(username=username).first()
309
if userCheck:
310
flask.flash(
311
Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>The username {username} is taken"),
312
category="error")
313
return flask.render_template("login.html")
314
315
if password2 != password:
316
flask.flash(Markup("<iconify-icon icon='mdi:key-alert'></iconify-icon>Make sure the passwords match"),
317
category="error")
318
return flask.render_template("login.html")
319
320
user = User(username, password, email, name)
321
db.session.add(user)
322
db.session.commit()
323
flask.session["username"] = user.username
324
flask.flash(Markup(
325
f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully created and logged in as {username}"),
326
category="success")
327
return flask.redirect("/", code=303)
328
329
330
@app.route("/newrepo/", methods=["GET", "POST"])
331
def newRepo():
332
if flask.request.method == "GET":
333
return flask.render_template("new-repo.html")
334
else:
335
name = flask.request.form["name"]
336
visibility = int(flask.request.form["visibility"])
337
338
if not onlyChars(name, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"):
339
flask.flash(Markup(
340
"<iconify-icon icon='mdi:error'></iconify-icon>Repository names may only contain Latin alphabet, numbers, '-' and '_'"),
341
category="error")
342
return flask.render_template("new-repo.html")
343
344
user = User.query.filter_by(username=flask.session.get("username")).first()
345
346
repo = Repo(user, name, visibility)
347
db.session.add(repo)
348
db.session.commit()
349
350
if not os.path.exists(os.path.join(config.REPOS_PATH, repo.route)):
351
subprocess.run(["git", "init", repo.name],
352
cwd=os.path.join(config.REPOS_PATH, flask.session.get("username")))
353
354
flask.flash(Markup(f"<iconify-icon icon='mdi:folder'></iconify-icon>Successfully created repository {name}"),
355
category="success")
356
return flask.redirect(repo.route, code=303)
357
358
359
@app.route("/logout")
360
def logout():
361
flask.session.clear()
362
flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged out"), category="info")
363
return flask.redirect("/", code=303)
364
365
366
@app.route("/<username>/")
367
def userProfile(username):
368
user = User.query.filter_by(username=username).first()
369
repos = Repo.query.filter_by(ownerName=username, visibility=2)
370
return flask.render_template("user-profile.html", user=user, repos=repos)
371
372
373
@app.route("/<username>/<repository>/")
374
def repositoryIndex(username, repository):
375
return flask.redirect("./tree", code=302)
376
377
378
@app.route("/<username>/<repository>/raw/<branch>/<path:subpath>")
379
def repositoryRaw(username, repository, branch, subpath):
380
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("user"), username,
381
repository) is not None):
382
flask.abort(403)
383
384
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
385
386
app.logger.info(f"Loading {serverRepoLocation}")
387
388
if not os.path.exists(serverRepoLocation):
389
app.logger.error(f"Cannot load {serverRepoLocation}")
390
return flask.render_template("not-found.html"), 404
391
392
repo = git.Repo(serverRepoLocation)
393
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
394
if not repoData.defaultBranch:
395
if repo.heads:
396
repoData.defaultBranch = repo.heads[0].name
397
else:
398
return flask.render_template("empty.html",
399
remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200
400
if not branch:
401
branch = repoData.defaultBranch
402
return flask.redirect(f"./{branch}", code=302)
403
404
if branch.startswith("tag:"):
405
ref = f"tags/{branch[4:]}"
406
elif branch.startswith("~"):
407
ref = branch[1:]
408
else:
409
ref = f"heads/{branch}"
410
411
ref = ref.replace("~", "/") # encode slashes for URL support
412
413
try:
414
repo.git.checkout("-f", ref)
415
except git.exc.GitCommandError:
416
return flask.render_template("not-found.html"), 404
417
418
return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath))
419
420
421
@app.route("/info/<username>/avatar")
422
def userAvatar(username):
423
serverUserdataLocation = os.path.join(config.USERDATA_PATH, username)
424
425
if not os.path.exists(serverUserdataLocation):
426
return flask.render_template("not-found.html"), 404
427
428
return flask.send_from_directory(serverUserdataLocation, "avatar.png")
429
430
431
@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""})
432
@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""})
433
@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>")
434
def repositoryTree(username, repository, branch, subpath):
435
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
436
repository) is not None):
437
flask.abort(403)
438
439
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
440
441
app.logger.info(f"Loading {serverRepoLocation}")
442
443
if not os.path.exists(serverRepoLocation):
444
app.logger.error(f"Cannot load {serverRepoLocation}")
445
return flask.render_template("not-found.html"), 404
446
447
repo = git.Repo(serverRepoLocation)
448
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
449
if not repoData.defaultBranch:
450
if repo.heads:
451
repoData.defaultBranch = repo.heads[0].name
452
else:
453
return flask.render_template("empty.html",
454
remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200
455
if not branch:
456
branch = repoData.defaultBranch
457
return flask.redirect(f"./{branch}", code=302)
458
459
if branch.startswith("tag:"):
460
ref = f"tags/{branch[4:]}"
461
elif branch.startswith("~"):
462
ref = branch[1:]
463
else:
464
ref = f"heads/{branch}"
465
466
ref = ref.replace("~", "/") # encode slashes for URL support
467
468
try:
469
repo.git.checkout("-f", ref)
470
except git.exc.GitCommandError:
471
return flask.render_template("not-found.html"), 404
472
473
branches = repo.heads
474
475
allRefs = []
476
for ref in repo.heads:
477
allRefs.append((ref, "head"))
478
for ref in repo.tags:
479
allRefs.append((ref, "tag"))
480
481
if os.path.isdir(os.path.join(serverRepoLocation, subpath)):
482
files = []
483
blobs = []
484
485
for entry in os.listdir(os.path.join(serverRepoLocation, subpath)):
486
if not os.path.basename(entry) == ".git":
487
files.append(os.path.join(subpath, entry))
488
489
infos = []
490
491
for file in files:
492
path = os.path.join(serverRepoLocation, file)
493
mimetype = guessMIME(path)
494
495
text = gitCommand(serverRepoLocation, None, "log", "--format='%H\n'", file).decode()
496
497
sha = text.split("\n")[0]
498
identifier = f"/{username}/{repository}/{sha}"
499
lastCommit = Commit.query.filter_by(identifier=identifier).first()
500
501
info = {
502
"name": os.path.basename(file),
503
"serverPath": path,
504
"relativePath": file,
505
"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file),
506
"size": humanSize(os.path.getsize(path)),
507
"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}",
508
"commit": lastCommit,
509
"shaSize": 7,
510
}
511
512
specialIcon = config.matchIcon(os.path.basename(file))
513
if specialIcon:
514
info["icon"] = specialIcon
515
elif os.path.isdir(path):
516
info["icon"] = config.folderIcon
517
elif mimetypes.guess_type(path)[0] in config.fileIcons:
518
info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]]
519
else:
520
info["icon"] = config.unknownIcon
521
522
if os.path.isdir(path):
523
infos.insert(0, info)
524
else:
525
infos.append(info)
526
527
return flask.render_template(
528
"repo-tree.html",
529
username=username,
530
repository=repository,
531
files=infos,
532
subpath=os.path.join("/", subpath),
533
branches=allRefs,
534
current=branch
535
)
536
else:
537
path = os.path.join(serverRepoLocation, subpath)
538
539
if not os.path.exists(path):
540
return flask.render_template("not-found.html"), 404
541
542
mimetype = guessMIME(path)
543
mode = mimetype.split("/", 1)[0]
544
size = humanSize(os.path.getsize(path))
545
546
specialIcon = config.matchIcon(os.path.basename(path))
547
if specialIcon:
548
icon = specialIcon
549
elif os.path.isdir(path):
550
icon = config.folderIcon
551
elif mimetypes.guess_type(path)[0] in config.fileIcons:
552
icon = config.fileIcons[mimetypes.guess_type(path)[0]]
553
else:
554
icon = config.unknownIcon
555
556
contents = None
557
if mode == "text":
558
contents = convertToHTML(path)
559
560
return flask.render_template(
561
"repo-file.html",
562
username=username,
563
repository=repository,
564
file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath),
565
branches=allRefs,
566
current=branch,
567
mode=mode,
568
mimetype=mimetype,
569
detailedtype=magic.from_file(path),
570
size=size,
571
icon=icon,
572
subpath=os.path.join("/", subpath),
573
basename=os.path.basename(path),
574
contents=contents
575
)
576
577
578
@app.route("/<username>/<repository>/forum/")
579
def repositoryForum(username, repository):
580
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
581
repository) is not None):
582
flask.abort(403)
583
584
return flask.render_template("repo-forum.html", username=username, repository=repository)
585
586
587
@app.route("/<username>/<repository>/branches/")
588
def repositoryBranches(username, repository):
589
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
590
repository) is not None):
591
flask.abort(403)
592
593
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
594
595
app.logger.info(f"Loading {serverRepoLocation}")
596
597
if not os.path.exists(serverRepoLocation):
598
app.logger.error(f"Cannot load {serverRepoLocation}")
599
return flask.render_template("not-found.html"), 404
600
601
repo = git.Repo(serverRepoLocation)
602
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
603
604
return flask.render_template("repo-branches.html", username=username, repository=repository, repoData=repoData, repo=repo)
605
606
607
@app.route("/<username>/<repository>/log/")
608
def repositoryLog(username, repository):
609
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
610
repository) is not None):
611
flask.abort(403)
612
613
return flask.render_template("repo-log.html", username=username, repository=repository)
614
615
616
@app.route("/<username>/<repository>/settings/")
617
def repositorySettings(username, repository):
618
if getPermissionLevel(flask.session.get("username"), username, repository) != 2:
619
flask.abort(401)
620
621
return flask.render_template("repo-settings.html", username=username, repository=repository)
622
623
624
@app.errorhandler(404)
625
def e404(error):
626
return flask.render_template("not-found.html"), 404
627
628
629
@app.errorhandler(401)
630
def e401(error):
631
return flask.render_template("unauthorised.html"), 401
632
633
634
@app.errorhandler(403)
635
def e403(error):
636
return flask.render_template("forbidden.html"), 403
637
638
639
@app.errorhandler(418)
640
def e418(error):
641
return flask.render_template("teapot.html"), 418
642
643
644
if __name__ == "__main__":
645
app.run(debug=True, port=8080, host="0.0.0.0")
646