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