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 • 22.66 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
try:
394
repo.git.checkout(branch)
395
except git.exc.GitCommandError:
396
return flask.render_template("not-found.html"), 404
397
398
return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath))
399
400
401
@app.route("/info/<username>/avatar")
402
def userAvatar(username):
403
serverUserdataLocation = os.path.join(config.USERDATA_PATH, username)
404
405
if not os.path.exists(serverUserdataLocation):
406
return flask.render_template("not-found.html"), 404
407
408
return flask.send_from_directory(serverUserdataLocation, "avatar.png")
409
410
411
@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""})
412
@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""})
413
@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>")
414
def repositoryTree(username, repository, branch, subpath):
415
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
416
repository) is not None):
417
flask.abort(403)
418
419
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
420
421
app.logger.info(f"Loading {serverRepoLocation}")
422
423
if not os.path.exists(serverRepoLocation):
424
app.logger.error(f"Cannot load {serverRepoLocation}")
425
return flask.render_template("not-found.html"), 404
426
427
repo = git.Repo(serverRepoLocation)
428
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
429
if not repoData.defaultBranch:
430
if repo.heads:
431
repoData.defaultBranch = repo.heads[0].name
432
else:
433
return flask.render_template("empty.html",
434
remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200
435
if not branch:
436
branch = repoData.defaultBranch
437
return flask.redirect(f"./{branch}", code=302)
438
439
if branch.startswith("tag:"):
440
ref = f"tags/{branch[4:]}"
441
elif branch.startswith("~"):
442
ref = branch[1:]
443
else:
444
ref = f"heads/{branch}"
445
446
ref = ref.replace("~", "/") # encode slashes for URL support
447
448
try:
449
repo.git.checkout("-f", ref)
450
except git.exc.GitCommandError:
451
return flask.render_template("not-found.html"), 404
452
453
branches = repo.heads
454
455
allRefs = []
456
for ref in repo.heads:
457
allRefs.append((ref, "head"))
458
for ref in repo.tags:
459
allRefs.append((ref, "tag"))
460
461
if os.path.isdir(os.path.join(serverRepoLocation, subpath)):
462
files = []
463
blobs = []
464
465
for entry in os.listdir(os.path.join(serverRepoLocation, subpath)):
466
if not os.path.basename(entry) == ".git":
467
files.append(os.path.join(subpath, entry))
468
469
infos = []
470
471
for file in files:
472
path = os.path.join(serverRepoLocation, file)
473
mimetype = guessMIME(path)
474
475
text = gitCommand(serverRepoLocation, None, "log", "--format='%H\n'", file).decode()
476
477
sha = text.split("\n")[0]
478
identifier = f"/{username}/{repository}/{sha}"
479
lastCommit = Commit.query.filter_by(identifier=identifier).first()
480
481
info = {
482
"name": os.path.basename(file),
483
"serverPath": path,
484
"relativePath": file,
485
"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file),
486
"size": humanSize(os.path.getsize(path)),
487
"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}",
488
"commit": lastCommit,
489
"shaSize": 7,
490
}
491
492
specialIcon = config.matchIcon(os.path.basename(file))
493
if specialIcon:
494
info["icon"] = specialIcon
495
elif os.path.isdir(path):
496
info["icon"] = config.folderIcon
497
elif mimetypes.guess_type(path)[0] in config.fileIcons:
498
info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]]
499
else:
500
info["icon"] = config.unknownIcon
501
502
if os.path.isdir(path):
503
infos.insert(0, info)
504
else:
505
infos.append(info)
506
507
return flask.render_template(
508
"repo-tree.html",
509
username=username,
510
repository=repository,
511
files=infos,
512
subpath=os.path.join("/", subpath),
513
branches=allRefs,
514
current=branch
515
)
516
else:
517
path = os.path.join(serverRepoLocation, subpath)
518
519
if not os.path.exists(path):
520
return flask.render_template("not-found.html"), 404
521
522
mimetype = guessMIME(path)
523
mode = mimetype.split("/", 1)[0]
524
size = humanSize(os.path.getsize(path))
525
526
specialIcon = config.matchIcon(os.path.basename(path))
527
if specialIcon:
528
icon = specialIcon
529
elif os.path.isdir(path):
530
icon = config.folderIcon
531
elif mimetypes.guess_type(path)[0] in config.fileIcons:
532
icon = config.fileIcons[mimetypes.guess_type(path)[0]]
533
else:
534
icon = config.unknownIcon
535
536
contents = None
537
if mode == "text":
538
contents = convertToHTML(path)
539
540
return flask.render_template(
541
"repo-file.html",
542
username=username,
543
repository=repository,
544
file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath),
545
branches=allRefs,
546
current=branch,
547
mode=mode,
548
mimetype=mimetype,
549
detailedtype=magic.from_file(path),
550
size=size,
551
icon=icon,
552
subpath=os.path.join("/", subpath),
553
basename=os.path.basename(path),
554
contents=contents
555
)
556
557
558
@app.route("/<username>/<repository>/forum/")
559
def repositoryForum(username, repository):
560
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
561
repository) is not None):
562
flask.abort(403)
563
564
return flask.render_template("repo-forum.html", username=username, repository=repository)
565
566
567
@app.route("/<username>/<repository>/branches/")
568
def repositoryBranches(username, repository):
569
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
570
repository) is not None):
571
flask.abort(403)
572
573
return flask.render_template("repo-branches.html", username=username, repository=repository)
574
575
576
@app.route("/<username>/<repository>/log/")
577
def repositoryLog(username, repository):
578
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
579
repository) is not None):
580
flask.abort(403)
581
582
return flask.render_template("repo-log.html", username=username, repository=repository)
583
584
585
@app.route("/<username>/<repository>/settings/")
586
def repositorySettings(username, repository):
587
if getPermissionLevel(flask.session.get("username"), username, repository) != 2:
588
flask.abort(401)
589
590
return flask.render_template("repo-settings.html", username=username, repository=repository)
591
592
593
@app.errorhandler(404)
594
def e404(error):
595
return flask.render_template("not-found.html"), 404
596
597
598
@app.errorhandler(401)
599
def e401(error):
600
return flask.render_template("unauthorised.html"), 401
601
602
603
@app.errorhandler(403)
604
def e403(error):
605
return flask.render_template("forbidden.html"), 403
606
607
608
@app.errorhandler(418)
609
def e418(error):
610
return flask.render_template("teapot.html"), 418
611
612
613
if __name__ == "__main__":
614
app.run(debug=True, port=8080, host="0.0.0.0")
615