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.29 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
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
362
363
app.logger.info(f"Loading {serverRepoLocation}")
364
365
if not os.path.exists(serverRepoLocation):
366
app.logger.error(f"Cannot load {serverRepoLocation}")
367
return flask.render_template("not-found.html"), 404
368
369
repo = git.Repo(serverRepoLocation)
370
try:
371
repo.git.checkout(branch)
372
except git.exc.GitCommandError:
373
return flask.render_template("not-found.html"), 404
374
375
return flask.send_from_directory(config.REPOS_PATH, os.path.join(username, repository, subpath))
376
377
378
@app.route("/info/<username>/avatar")
379
def userAvatar(username):
380
serverUserdataLocation = os.path.join(config.USERDATA_PATH, username)
381
382
if not os.path.exists(serverUserdataLocation):
383
return flask.render_template("not-found.html"), 404
384
385
return flask.send_from_directory(serverUserdataLocation, "avatar.png")
386
387
388
@app.route("/<username>/<repository>/tree/", defaults={"branch": None, "subpath": ""})
389
@app.route("/<username>/<repository>/tree/<branch>/", defaults={"subpath": ""})
390
@app.route("/<username>/<repository>/tree/<branch>/<path:subpath>")
391
def repositoryTree(username, repository, branch, subpath):
392
serverRepoLocation = os.path.join(config.REPOS_PATH, os.path.join(username, repository))
393
394
app.logger.info(f"Loading {serverRepoLocation}")
395
396
if not os.path.exists(serverRepoLocation):
397
app.logger.error(f"Cannot load {serverRepoLocation}")
398
return flask.render_template("not-found.html"), 404
399
400
repo = git.Repo(serverRepoLocation)
401
repoData = Repo.query.filter_by(route=f"/{username}/{repository}").first()
402
if not repoData.defaultBranch:
403
if repo.heads:
404
repoData.defaultBranch = repo.heads[0].name
405
else:
406
return flask.render_template("empty.html", remote=f"http://{config.BASE_DOMAIN}/git/{username}/{repository}"), 200
407
if not branch:
408
branch = repoData.defaultBranch
409
return flask.redirect(f"./{branch}", code=302)
410
else:
411
try:
412
repo.git.checkout("-f", branch)
413
except git.exc.GitCommandError:
414
return flask.render_template("not-found.html"), 404
415
416
branches = repo.heads
417
418
if os.path.isdir(os.path.join(serverRepoLocation, subpath)):
419
files = []
420
blobs = []
421
422
for entry in os.listdir(os.path.join(serverRepoLocation, subpath)):
423
if not os.path.basename(entry) == ".git":
424
files.append(os.path.join(subpath, entry))
425
426
infos = []
427
428
for file in files:
429
path = os.path.join(serverRepoLocation, file)
430
mimetype = guessMIME(path)
431
432
text = gitCommand(serverRepoLocation, None, "log", "--format='%H\n'", file).decode()
433
434
sha = text.split("\n")[0]
435
identifier = f"/{username}/{repository}/{sha}"
436
lastCommit = Commit.query.filter_by(identifier=identifier).first()
437
438
info = {
439
"name": os.path.basename(file),
440
"serverPath": path,
441
"relativePath": file,
442
"link": os.path.join(f"/{username}/{repository}/tree/{branch}/", file),
443
"size": humanSize(os.path.getsize(path)),
444
"mimetype": f"{mimetype}{f' ({mimetypes.guess_type(path)[1]})' if mimetypes.guess_type(path)[1] else ''}",
445
"commit": lastCommit,
446
"shaSize": 7,
447
}
448
449
specialIcon = config.matchIcon(os.path.basename(file))
450
if specialIcon:
451
info["icon"] = specialIcon
452
elif os.path.isdir(path):
453
info["icon"] = config.folderIcon
454
elif mimetypes.guess_type(path)[0] in config.fileIcons:
455
info["icon"] = config.fileIcons[mimetypes.guess_type(path)[0]]
456
else:
457
info["icon"] = config.unknownIcon
458
459
if os.path.isdir(path):
460
infos.insert(0, info)
461
else:
462
infos.append(info)
463
464
return flask.render_template(
465
"repo-tree.html",
466
username=username,
467
repository=repository,
468
files=infos,
469
subpath=os.path.join("/", subpath),
470
branches=branches,
471
current=branch
472
)
473
else:
474
path = os.path.join(serverRepoLocation, subpath)
475
476
if not os.path.exists(path):
477
return flask.render_template("not-found.html"), 404
478
479
mimetype = guessMIME(path)
480
mode = mimetype.split("/", 1)[0]
481
size = humanSize(os.path.getsize(path))
482
483
specialIcon = config.matchIcon(os.path.basename(path))
484
if specialIcon:
485
icon = specialIcon
486
elif os.path.isdir(path):
487
icon = config.folderIcon
488
elif guessMIME(path)[0] in config.fileIcons:
489
icon = config.fileIcons[guessMIME(path)[0]]
490
else:
491
icon = config.unknownIcon
492
493
contents = None
494
if mode == "text":
495
contents = convertToHTML(path)
496
497
return flask.render_template(
498
"repo-file.html",
499
username=username,
500
repository=repository,
501
file=os.path.join(f"/{username}/{repository}/raw/{branch}/", subpath),
502
branches=branches,
503
current=branch,
504
mode=mode,
505
mimetype=mimetype,
506
detailedtype=magic.from_file(path),
507
size=size,
508
icon=icon,
509
subpath=os.path.join("/", subpath),
510
basename=os.path.basename(path),
511
contents=contents
512
)
513
514
515
@app.route("/<username>/<repository>/forum/")
516
def repositoryForum(username, repository):
517
return flask.render_template("repo-forum.html", username=username, repository=repository)
518
519
520
@app.route("/<username>/<repository>/docs/")
521
def repositoryDocs(username, repository):
522
return flask.render_template("repo-docs.html", username=username, repository=repository)
523
524
525
@app.route("/<username>/<repository>/releases/")
526
def repositoryReleases(username, repository):
527
return flask.render_template("repo-releases.html", username=username, repository=repository)
528
529
530
@app.route("/<username>/<repository>/branches/")
531
def repositoryBranches(username, repository):
532
return flask.render_template("repo-branches.html", username=username, repository=repository)
533
534
535
@app.route("/<username>/<repository>/people/")
536
def repositoryPeople(username, repository):
537
return flask.render_template("repo-people.html", username=username, repository=repository)
538
539
540
@app.route("/<username>/<repository>/activity/")
541
def repositoryActivity(username, repository):
542
return flask.render_template("repo-activity.html", username=username, repository=repository)
543
544
545
@app.route("/<username>/<repository>/ci/")
546
def repositoryCI(username, repository):
547
return flask.render_template("repo-ci.html", username=username, repository=repository)
548
549
550
@app.route("/<username>/<repository>/settings/")
551
def repositorySettings(username, repository):
552
flask.abort(401)
553
return flask.render_template("repo-settings.html", username=username, repository=repository)
554
555
556
@app.errorhandler(404)
557
def e404(error):
558
return flask.render_template("not-found.html"), 404
559
560
561
@app.errorhandler(401)
562
def e401(error):
563
return flask.render_template("unauthorised.html"), 401
564
565
566
@app.errorhandler(403)
567
def e403(error):
568
return flask.render_template("forbidden.html"), 403
569
570
571
@app.errorhandler(418)
572
def e418(error):
573
return flask.render_template("teapot.html"), 418
574
575