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.46 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
if branch.startswith("~"):
442
ref = branch[1:]
443
else:
444
ref = f"refs/{branch}"
445
446
try:
447
repo.git.checkout("-f", branch)
448
except git.exc.GitCommandError:
449
return flask.render_template("not-found.html"), 404
450
451
branches = repo.heads
452
453
if os.path.isdir(os.path.join(serverRepoLocation, subpath)):
454
files = []
455
blobs = []
456
457
for entry in os.listdir(os.path.join(serverRepoLocation, subpath)):
458
if not os.path.basename(entry) == ".git":
459
files.append(os.path.join(subpath, entry))
460
461
infos = []
462
463
for file in files:
464
path = os.path.join(serverRepoLocation, file)
465
mimetype = guessMIME(path)
466
467
text = gitCommand(serverRepoLocation, None, "log", "--format='%H\n'", file).decode()
468
469
sha = text.split("\n")[0]
470
identifier = f"/{username}/{repository}/{sha}"
471
lastCommit = Commit.query.filter_by(identifier=identifier).first()
472
473
info = {
474
"name": os.path.basename(file),
475
"serverPath": path,
476
"relativePath": file,
477
"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file),
478
"size": humanSize(os.path.getsize(path)),
479
"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}",
480
"commit": lastCommit,
481
"shaSize": 7,
482
}
483
484
specialIcon = config.matchIcon(os.path.basename(file))
485
if specialIcon:
486
info["icon"] = specialIcon
487
elif os.path.isdir(path):
488
info["icon"] = config.folderIcon
489
elif mimetypes.guess_type(path)[0] in config.fileIcons:
490
info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]]
491
else:
492
info["icon"] = config.unknownIcon
493
494
if os.path.isdir(path):
495
infos.insert(0, info)
496
else:
497
infos.append(info)
498
499
return flask.render_template(
500
"repo-tree.html",
501
username=username,
502
repository=repository,
503
files=infos,
504
subpath=os.path.join("/", subpath),
505
branches=branches,
506
current=branch
507
)
508
else:
509
path = os.path.join(serverRepoLocation, subpath)
510
511
if not os.path.exists(path):
512
return flask.render_template("not-found.html"), 404
513
514
mimetype = guessMIME(path)
515
mode = mimetype.split("/", 1)[0]
516
size = humanSize(os.path.getsize(path))
517
518
specialIcon = config.matchIcon(os.path.basename(path))
519
if specialIcon:
520
icon = specialIcon
521
elif os.path.isdir(path):
522
icon = config.folderIcon
523
elif mimetypes.guess_type(path)[0] in config.fileIcons:
524
icon = config.fileIcons[mimetypes.guess_type(path)[0]]
525
else:
526
icon = config.unknownIcon
527
528
contents = None
529
if mode == "text":
530
contents = convertToHTML(path)
531
532
return flask.render_template(
533
"repo-file.html",
534
username=username,
535
repository=repository,
536
file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath),
537
branches=branches,
538
current=branch,
539
mode=mode,
540
mimetype=mimetype,
541
detailedtype=magic.from_file(path),
542
size=size,
543
icon=icon,
544
subpath=os.path.join("/", subpath),
545
basename=os.path.basename(path),
546
contents=contents
547
)
548
549
550
@app.route("/<username>/<repository>/forum/")
551
def repositoryForum(username, repository):
552
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
553
repository) is not None):
554
flask.abort(403)
555
556
return flask.render_template("repo-forum.html", username=username, repository=repository)
557
558
559
@app.route("/<username>/<repository>/branches/")
560
def repositoryBranches(username, repository):
561
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
562
repository) is not None):
563
flask.abort(403)
564
565
return flask.render_template("repo-branches.html", username=username, repository=repository)
566
567
568
@app.route("/<username>/<repository>/log/")
569
def repositoryLog(username, repository):
570
if not (getVisibility(username, repository) or getPermissionLevel(flask.session.get("username"), username,
571
repository) is not None):
572
flask.abort(403)
573
574
return flask.render_template("repo-log.html", username=username, repository=repository)
575
576
577
@app.route("/<username>/<repository>/settings/")
578
def repositorySettings(username, repository):
579
if getPermissionLevel(flask.session.get("username"), username, repository) != 2:
580
flask.abort(401)
581
582
return flask.render_template("repo-settings.html", username=username, repository=repository)
583
584
585
@app.errorhandler(404)
586
def e404(error):
587
return flask.render_template("not-found.html"), 404
588
589
590
@app.errorhandler(401)
591
def e401(error):
592
return flask.render_template("unauthorised.html"), 401
593
594
595
@app.errorhandler(403)
596
def e403(error):
597
return flask.render_template("forbidden.html"), 403
598
599
600
@app.errorhandler(418)
601
def e418(error):
602
return flask.render_template("teapot.html"), 418
603
604
605
if __name__ == "__main__":
606
app.run(debug=True, port=8080, host="0.0.0.0")
607