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