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 • 24.37 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.now)
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
import jinjaUtils
193
194
195
def humanSize(value, decimals=2, scale=1024,
196
units=("B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")):
197
for unit in units:
198
if value < scale:
199
break
200
value /= scale
201
if int(value) == value:
202
# do not return decimals, if the value is already round
203
return int(value), unit
204
return round(value * 10 ** decimals) / 10 ** decimals, unit
205
206
207
def guessMIME(path):
208
if os.path.isdir(path):
209
mimetype = "inode/directory"
210
elif magic.from_file(path, mime=True):
211
mimetype = magic.from_file(path, mime=True)
212
else:
213
mimetype = "application/octet-stream"
214
return mimetype
215
216
217
def convertToHTML(path):
218
with open(path, "r") as f:
219
contents = f.read()
220
return contents
221
222
223
@app.context_processor
224
def default():
225
username = flask.session.get("username")
226
227
return {"loggedInUser": username}
228
229
230
@app.route("/")
231
def main():
232
return flask.render_template("home.html")
233
234
235
@app.route("/about/")
236
def about():
237
return flask.render_template("about.html", platform=platform)
238
239
240
@app.route("/settings/", methods=["GET", "POST"])
241
def settings():
242
if flask.request.method == "GET":
243
if not flask.session.get("username"):
244
flask.abort(401)
245
user = User.query.filter_by(username=flask.session.get("username")).first()
246
247
return flask.render_template("user-settings.html", user=user)
248
else:
249
user = User.query.filter_by(username=flask.session.get("username")).first()
250
251
user.displayName = flask.request.form["displayname"]
252
user.URL = flask.request.form["url"]
253
user.company = flask.request.form["company"]
254
user.companyURL = flask.request.form["companyurl"]
255
user.location = flask.request.form["location"]
256
user.showMail = flask.request.form.get("showmail", user.showMail)
257
258
db.session.commit()
259
260
flask.flash(Markup("<iconify-icon icon='mdi:check'></iconify-icon>Settings saved"), category="success")
261
return flask.redirect(f"/{flask.session.get('username')}", code=303)
262
263
264
@app.route("/accounts/", methods=["GET", "POST"])
265
def login():
266
if flask.request.method == "GET":
267
return flask.render_template("login.html")
268
else:
269
if "login" in flask.request.form:
270
username = flask.request.form["username"]
271
password = flask.request.form["password"]
272
273
user = User.query.filter_by(username=username).first()
274
275
if user and bcrypt.check_password_hash(user.passwordHashed, password):
276
flask.session["username"] = user.username
277
flask.flash(
278
Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged in as {username}"),
279
category="success")
280
return flask.redirect("/", code=303)
281
elif not user:
282
flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>User not found"),
283
category="alert")
284
return flask.render_template("login.html")
285
else:
286
flask.flash(Markup("<iconify-icon icon='mdi:account-question'></iconify-icon>Invalid password"),
287
category="error")
288
return flask.render_template("login.html")
289
if "signup" in flask.request.form:
290
username = flask.request.form["username"]
291
password = flask.request.form["password"]
292
password2 = flask.request.form["password2"]
293
email = flask.request.form.get("email")
294
email2 = flask.request.form.get("email2") # repeat email is a honeypot
295
name = flask.request.form.get("name")
296
297
if not onlyChars(username, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"):
298
flask.flash(Markup(
299
"<iconify-icon icon='mdi:account-error'></iconify-icon>Usernames may only contain Latin alphabet, numbers, '-' and '_'"),
300
category="error")
301
return flask.render_template("login.html")
302
303
if username in config.RESERVED_NAMES:
304
flask.flash(
305
Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>Sorry, {username} is a system path"),
306
category="error")
307
return flask.render_template("login.html")
308
309
userCheck = User.query.filter_by(username=username).first()
310
if userCheck:
311
flask.flash(
312
Markup(f"<iconify-icon icon='mdi:account-error'></iconify-icon>The username {username} is taken"),
313
category="error")
314
return flask.render_template("login.html")
315
316
if password2 != password:
317
flask.flash(Markup("<iconify-icon icon='mdi:key-alert'></iconify-icon>Make sure the passwords match"),
318
category="error")
319
return flask.render_template("login.html")
320
321
user = User(username, password, email, name)
322
db.session.add(user)
323
db.session.commit()
324
flask.session["username"] = user.username
325
flask.flash(Markup(
326
f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully created and logged in as {username}"),
327
category="success")
328
return flask.redirect("/", code=303)
329
330
331
@app.route("/newrepo/", methods=["GET", "POST"])
332
def newRepo():
333
if flask.request.method == "GET":
334
return flask.render_template("new-repo.html")
335
else:
336
name = flask.request.form["name"]
337
visibility = int(flask.request.form["visibility"])
338
339
if not onlyChars(name, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"):
340
flask.flash(Markup(
341
"<iconify-icon icon='mdi:error'></iconify-icon>Repository names may only contain Latin alphabet, numbers, '-' and '_'"),
342
category="error")
343
return flask.render_template("new-repo.html")
344
345
user = User.query.filter_by(username=flask.session.get("username")).first()
346
347
repo = Repo(user, name, visibility)
348
db.session.add(repo)
349
db.session.commit()
350
351
if not os.path.exists(os.path.join(config.REPOS_PATH, repo.route)):
352
subprocess.run(["git", "init", repo.name],
353
cwd=os.path.join(config.REPOS_PATH, flask.session.get("username")))
354
355
flask.flash(Markup(f"<iconify-icon icon='mdi:folder'></iconify-icon>Successfully created repository {name}"),
356
category="success")
357
return flask.redirect(repo.route, code=303)
358
359
360
@app.route("/logout")
361
def logout():
362
flask.session.clear()
363
flask.flash(Markup(f"<iconify-icon icon='mdi:account'></iconify-icon>Successfully logged out"), category="info")
364
return flask.redirect("/", code=303)
365
366
367
@app.route("/<username>/")
368
def userProfile(username):
369
user = User.query.filter_by(username=username).first()
370
repos = Repo.query.filter_by(ownerName=username, visibility=2)
371
return flask.render_template("user-profile.html", user=user, repos=repos)
372
373
374
@app.route("/<username>/<repository>/")
375
def repositoryIndex(username, repository):
376
return flask.redirect("./tree", code=302)
377
378
379
@app.route("/<username>/<repository>/raw/<branch>/<path:subpath>")
380
def repositoryRaw(username, repository, branch, subpath):
381
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("user"), username,
382
repository) is not None):
383
flask.abort(403)
384
385
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
386
387
app.logger.info(f"Loading {serverRepoLocation}")
388
389
if not os.path.exists(serverRepoLocation):
390
app.logger.error(f"Cannot load {serverRepoLocation}")
391
return flask.render_template("not-found.html"), 404
392
393
repo = git.Repo(serverRepoLocation)
394
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
395
if not repoData.defaultBranch:
396
if repo.heads:
397
repoData.defaultBranch = repo.heads[0].name
398
else:
399
return flask.render_template("empty.html",
400
remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200
401
if not branch:
402
branch = repoData.defaultBranch
403
return flask.redirect(f"./{branch}", code=302)
404
405
if branch.startswith("tag:"):
406
ref = f"tags/{branch[4:]}"
407
elif branch.startswith("~"):
408
ref = branch[1:]
409
else:
410
ref = f"heads/{branch}"
411
412
ref = ref.replace("~", "/") # encode slashes for URL support
413
414
try:
415
repo.git.checkout("-f", ref)
416
except git.exc.GitCommandError:
417
return flask.render_template("not-found.html"), 404
418
419
return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath))
420
421
422
@app.route("/info/<username>/avatar")
423
def userAvatar(username):
424
serverUserdataLocation = os.path.join(config.USERDATA_PATH, username)
425
426
if not os.path.exists(serverUserdataLocation):
427
return flask.render_template("not-found.html"), 404
428
429
return flask.send_from_directory(serverUserdataLocation, "avatar.png")
430
431
432
@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""})
433
@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""})
434
@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>")
435
def repositoryTree(username, repository, branch, subpath):
436
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
437
repository) is not None):
438
flask.abort(403)
439
440
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
441
442
app.logger.info(f"Loading {serverRepoLocation}")
443
444
if not os.path.exists(serverRepoLocation):
445
app.logger.error(f"Cannot load {serverRepoLocation}")
446
return flask.render_template("not-found.html"), 404
447
448
repo = git.Repo(serverRepoLocation)
449
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
450
if not repoData.defaultBranch:
451
if repo.heads:
452
repoData.defaultBranch = repo.heads[0].name
453
else:
454
return flask.render_template("empty.html",
455
remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200
456
if not branch:
457
branch = repoData.defaultBranch
458
return flask.redirect(f"./{branch}", code=302)
459
460
if branch.startswith("tag:"):
461
ref = f"tags/{branch[4:]}"
462
elif branch.startswith("~"):
463
ref = branch[1:]
464
else:
465
ref = f"heads/{branch}"
466
467
ref = ref.replace("~", "/") # encode slashes for URL support
468
469
try:
470
repo.git.checkout("-f", ref)
471
except git.exc.GitCommandError:
472
return flask.render_template("not-found.html"), 404
473
474
branches = repo.heads
475
476
allRefs = []
477
for ref in repo.heads:
478
allRefs.append((ref, "head"))
479
for ref in repo.tags:
480
allRefs.append((ref, "tag"))
481
482
if os.path.isdir(os.path.join(serverRepoLocation, subpath)):
483
files = []
484
blobs = []
485
486
for entry in os.listdir(os.path.join(serverRepoLocation, subpath)):
487
if not os.path.basename(entry) == ".git":
488
files.append(os.path.join(subpath, entry))
489
490
infos = []
491
492
for file in files:
493
path = os.path.join(serverRepoLocation, file)
494
mimetype = guessMIME(path)
495
496
text = gitCommand(serverRepoLocation, None, "log", "--format='%H\n'", file).decode()
497
498
sha = text.split("\n")[0]
499
identifier = f"/{username}/{repository}/{sha}"
500
lastCommit = Commit.query.filter_by(identifier=identifier).first()
501
502
info = {
503
"name": os.path.basename(file),
504
"serverPath": path,
505
"relativePath": file,
506
"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file),
507
"size": humanSize(os.path.getsize(path)),
508
"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}",
509
"commit": lastCommit,
510
"shaSize": 7,
511
}
512
513
specialIcon = config.matchIcon(os.path.basename(file))
514
if specialIcon:
515
info["icon"] = specialIcon
516
elif os.path.isdir(path):
517
info["icon"] = config.folderIcon
518
elif mimetypes.guess_type(path)[0] in config.fileIcons:
519
info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]]
520
else:
521
info["icon"] = config.unknownIcon
522
523
if os.path.isdir(path):
524
infos.insert(0, info)
525
else:
526
infos.append(info)
527
528
return flask.render_template(
529
"repo-tree.html",
530
username=username,
531
repository=repository,
532
files=infos,
533
subpath=os.path.join("/", subpath),
534
branches=allRefs,
535
current=branch
536
)
537
else:
538
path = os.path.join(serverRepoLocation, subpath)
539
540
if not os.path.exists(path):
541
return flask.render_template("not-found.html"), 404
542
543
mimetype = guessMIME(path)
544
mode = mimetype.split("/", 1)[0]
545
size = humanSize(os.path.getsize(path))
546
547
specialIcon = config.matchIcon(os.path.basename(path))
548
if specialIcon:
549
icon = specialIcon
550
elif os.path.isdir(path):
551
icon = config.folderIcon
552
elif mimetypes.guess_type(path)[0] in config.fileIcons:
553
icon = config.fileIcons[mimetypes.guess_type(path)[0]]
554
else:
555
icon = config.unknownIcon
556
557
contents = None
558
if mode == "text":
559
contents = convertToHTML(path)
560
561
return flask.render_template(
562
"repo-file.html",
563
username=username,
564
repository=repository,
565
file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath),
566
branches=allRefs,
567
current=branch,
568
mode=mode,
569
mimetype=mimetype,
570
detailedtype=magic.from_file(path),
571
size=size,
572
icon=icon,
573
subpath=os.path.join("/", subpath),
574
basename=os.path.basename(path),
575
contents=contents
576
)
577
578
579
@app.route("/<username>/<repository>/forum/")
580
def repositoryForum(username, repository):
581
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
582
repository) is not None):
583
flask.abort(403)
584
585
return flask.render_template("repo-forum.html", username=username, repository=repository)
586
587
588
@app.route("/<username>/<repository>/branches/")
589
def repositoryBranches(username, repository):
590
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
591
repository) is not None):
592
flask.abort(403)
593
594
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
595
596
app.logger.info(f"Loading {serverRepoLocation}")
597
598
if not os.path.exists(serverRepoLocation):
599
app.logger.error(f"Cannot load {serverRepoLocation}")
600
return flask.render_template("not-found.html"), 404
601
602
repo = git.Repo(serverRepoLocation)
603
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
604
605
return flask.render_template("repo-branches.html", username=username, repository=repository, repoData=repoData, repo=repo)
606
607
608
@app.route("/<username>/<repository>/log/")
609
def repositoryLog(username, repository):
610
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
611
repository) is not None):
612
flask.abort(403)
613
614
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
615
616
app.logger.info(f"Loading {serverRepoLocation}")
617
618
if not os.path.exists(serverRepoLocation):
619
app.logger.error(f"Cannot load {serverRepoLocation}")
620
return flask.render_template("not-found.html"), 404
621
622
repo = git.Repo(serverRepoLocation)
623
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
624
commits = Commit.query.filter_by(repo=repoData)
625
626
return flask.render_template("repo-log.html", username=username, repository=repository, repoData=repoData, repo=repo, commits=commits)
627
628
629
@app.route("/<username>/<repository>/settings/")
630
def repositorySettings(username, repository):
631
if getPermissionLevel(flask.session.get("username"), username, repository) != 2:
632
flask.abort(401)
633
634
return flask.render_template("repo-settings.html", username=username, repository=repository)
635
636
637
@app.errorhandler(404)
638
def e404(error):
639
return flask.render_template("not-found.html"), 404
640
641
642
@app.errorhandler(401)
643
def e401(error):
644
return flask.render_template("unauthorised.html"), 401
645
646
647
@app.errorhandler(403)
648
def e403(error):
649
return flask.render_template("forbidden.html"), 403
650
651
652
@app.errorhandler(418)
653
def e418(error):
654
return flask.render_template("teapot.html"), 418
655
656
657
if __name__ == "__main__":
658
app.run(debug=True, port=8080, host="0.0.0.0")
659