git-serve: Add support for (un-)voting
[aur.git] / aurweb / git / serve.py
blob4c03e3b6f2ddd75ce358aef33945d003c8521cec
1 #!/usr/bin/python3
3 import os
4 import re
5 import shlex
6 import subprocess
7 import sys
8 import time
10 import aurweb.config
11 import aurweb.db
12 import aurweb.exceptions
14 notify_cmd = aurweb.config.get('notifications', 'notify-cmd')
16 repo_path = aurweb.config.get('serve', 'repo-path')
17 repo_regex = aurweb.config.get('serve', 'repo-regex')
18 git_shell_cmd = aurweb.config.get('serve', 'git-shell-cmd')
19 git_update_cmd = aurweb.config.get('serve', 'git-update-cmd')
20 ssh_cmdline = aurweb.config.get('serve', 'ssh-cmdline')
22 enable_maintenance = aurweb.config.getboolean('options', 'enable-maintenance')
23 maintenance_exc = aurweb.config.get('options', 'maintenance-exceptions').split()
26 def pkgbase_from_name(pkgbase):
27 conn = aurweb.db.Connection()
28 cur = conn.execute("SELECT ID FROM PackageBases WHERE Name = ?", [pkgbase])
30 row = cur.fetchone()
31 return row[0] if row else None
34 def pkgbase_exists(pkgbase):
35 return pkgbase_from_name(pkgbase) is not None
38 def list_repos(user):
39 conn = aurweb.db.Connection()
41 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
42 userid = cur.fetchone()[0]
43 if userid == 0:
44 raise aurweb.exceptions.InvalidUserException(user)
46 cur = conn.execute("SELECT Name, PackagerUID FROM PackageBases " +
47 "WHERE MaintainerUID = ?", [userid])
48 for row in cur:
49 print((' ' if row[1] else '*') + row[0])
50 conn.close()
53 def create_pkgbase(pkgbase, user):
54 if not re.match(repo_regex, pkgbase):
55 raise aurweb.exceptions.InvalidRepositoryNameException(pkgbase)
56 if pkgbase_exists(pkgbase):
57 raise aurweb.exceptions.PackageBaseExistsException(pkgbase)
59 conn = aurweb.db.Connection()
61 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
62 userid = cur.fetchone()[0]
63 if userid == 0:
64 raise aurweb.exceptions.InvalidUserException(user)
66 now = int(time.time())
67 cur = conn.execute("INSERT INTO PackageBases (Name, SubmittedTS, " +
68 "ModifiedTS, SubmitterUID, MaintainerUID) VALUES " +
69 "(?, ?, ?, ?, ?)", [pkgbase, now, now, userid, userid])
70 pkgbase_id = cur.lastrowid
72 cur = conn.execute("INSERT INTO PackageNotifications " +
73 "(PackageBaseID, UserID) VALUES (?, ?)",
74 [pkgbase_id, userid])
76 conn.commit()
77 conn.close()
80 def pkgbase_adopt(pkgbase, user, privileged):
81 pkgbase_id = pkgbase_from_name(pkgbase)
82 if not pkgbase_id:
83 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
85 conn = aurweb.db.Connection()
87 cur = conn.execute("SELECT ID FROM PackageBases WHERE ID = ? AND " +
88 "MaintainerUID IS NULL", [pkgbase_id])
89 if not privileged and not cur.fetchone():
90 raise aurweb.exceptions.PermissionDeniedException(user)
92 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
93 userid = cur.fetchone()[0]
94 if userid == 0:
95 raise aurweb.exceptions.InvalidUserException(user)
97 cur = conn.execute("UPDATE PackageBases SET MaintainerUID = ? " +
98 "WHERE ID = ?", [userid, pkgbase_id])
100 cur = conn.execute("SELECT COUNT(*) FROM PackageNotifications WHERE " +
101 "PackageBaseID = ? AND UserID = ?",
102 [pkgbase_id, userid])
103 if cur.fetchone()[0] == 0:
104 cur = conn.execute("INSERT INTO PackageNotifications " +
105 "(PackageBaseID, UserID) VALUES (?, ?)",
106 [pkgbase_id, userid])
107 conn.commit()
109 subprocess.Popen((notify_cmd, 'adopt', str(pkgbase_id), str(userid)))
111 conn.close()
114 def pkgbase_get_comaintainers(pkgbase):
115 conn = aurweb.db.Connection()
117 cur = conn.execute("SELECT UserName FROM PackageComaintainers " +
118 "INNER JOIN Users " +
119 "ON Users.ID = PackageComaintainers.UsersID " +
120 "INNER JOIN PackageBases " +
121 "ON PackageBases.ID = PackageComaintainers.PackageBaseID " +
122 "WHERE PackageBases.Name = ? " +
123 "ORDER BY Priority ASC", [pkgbase])
125 return [row[0] for row in cur.fetchall()]
128 def pkgbase_set_comaintainers(pkgbase, userlist, user, privileged):
129 pkgbase_id = pkgbase_from_name(pkgbase)
130 if not pkgbase_id:
131 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
133 if not privileged and not pkgbase_has_full_access(pkgbase, user):
134 raise aurweb.exceptions.PermissionDeniedException(user)
136 conn = aurweb.db.Connection()
138 userlist_old = set(pkgbase_get_comaintainers(pkgbase))
140 uids_old = set()
141 for olduser in userlist_old:
142 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?",
143 [olduser])
144 userid = cur.fetchone()[0]
145 if userid == 0:
146 raise aurweb.exceptions.InvalidUserException(user)
147 uids_old.add(userid)
149 uids_new = set()
150 for newuser in userlist:
151 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?",
152 [newuser])
153 userid = cur.fetchone()[0]
154 if userid == 0:
155 raise aurweb.exceptions.InvalidUserException(user)
156 uids_new.add(userid)
158 uids_add = uids_new - uids_old
159 uids_rem = uids_old - uids_new
161 i = 1
162 for userid in uids_new:
163 if userid in uids_add:
164 cur = conn.execute("INSERT INTO PackageComaintainers " +
165 "(PackageBaseID, UsersID, Priority) " +
166 "VALUES (?, ?, ?)", [pkgbase_id, userid, i])
167 subprocess.Popen((notify_cmd, 'comaintainer-add', str(pkgbase_id),
168 str(userid)))
169 else:
170 cur = conn.execute("UPDATE PackageComaintainers " +
171 "SET Priority = ? " +
172 "WHERE PackageBaseID = ? AND UsersID = ?",
173 [i, pkgbase_id, userid])
174 i += 1
176 for userid in uids_rem:
177 cur = conn.execute("DELETE FROM PackageComaintainers " +
178 "WHERE PackageBaseID = ? AND UsersID = ?",
179 [pkgbase_id, userid])
180 subprocess.Popen((notify_cmd, 'comaintainer-remove',
181 str(pkgbase_id), str(userid)))
183 conn.commit()
184 conn.close()
187 def pkgreq_by_pkgbase(pkgbase_id, reqtype):
188 conn = aurweb.db.Connection()
190 cur = conn.execute("SELECT PackageRequests.ID FROM PackageRequests " +
191 "INNER JOIN RequestTypes ON " +
192 "RequestTypes.ID = PackageRequests.ReqTypeID " +
193 "WHERE PackageRequests.Status = 0 " +
194 "AND PackageRequests.PackageBaseID = ?" +
195 "AND RequestTypes.Name = ?", [pkgbase_id, reqtype])
197 return [row[0] for row in cur.fetchall()]
200 def pkgreq_close(reqid, user, reason, comments, autoclose=False):
201 statusmap = {'accepted': 2, 'rejected': 3}
202 if reason not in statusmap:
203 raise aurweb.exceptions.InvalidReasonException(reason)
204 status = statusmap[reason]
206 conn = aurweb.db.Connection()
208 if autoclose:
209 userid = 0
210 else:
211 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
212 userid = cur.fetchone()[0]
213 if userid == 0:
214 raise aurweb.exceptions.InvalidUserException(user)
216 conn.execute("UPDATE PackageRequests SET Status = ?, ClosureComment = ? " +
217 "WHERE ID = ?", [status, comments, reqid])
218 conn.commit()
219 conn.close()
221 subprocess.Popen((notify_cmd, 'request-close', str(userid), str(reqid),
222 reason)).wait()
225 def pkgbase_disown(pkgbase, user, privileged):
226 pkgbase_id = pkgbase_from_name(pkgbase)
227 if not pkgbase_id:
228 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
230 initialized_by_owner = pkgbase_has_full_access(pkgbase, user)
231 if not privileged and not initialized_by_owner:
232 raise aurweb.exceptions.PermissionDeniedException(user)
234 # TODO: Support disowning package bases via package request.
236 # Scan through pending orphan requests and close them.
237 comment = 'The user {:s} disowned the package.'.format(user)
238 for reqid in pkgreq_by_pkgbase(pkgbase_id, 'orphan'):
239 pkgreq_close(reqid, user, 'accepted', comment, True)
241 comaintainers = []
242 new_maintainer_userid = None
244 conn = aurweb.db.Connection()
246 # Make the first co-maintainer the new maintainer, unless the action was
247 # enforced by a Trusted User.
248 if initialized_by_owner:
249 comaintainers = pkgbase_get_comaintainers(pkgbase)
250 if len(comaintainers) > 0:
251 new_maintainer = comaintainers[0]
252 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?",
253 [new_maintainer])
254 new_maintainer_userid = cur.fetchone()[0]
255 comaintainers.remove(new_maintainer)
257 pkgbase_set_comaintainers(pkgbase, comaintainers, user, privileged)
258 cur = conn.execute("UPDATE PackageBases SET MaintainerUID = ? " +
259 "WHERE ID = ?", [new_maintainer_userid, pkgbase_id])
261 conn.commit()
263 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
264 userid = cur.fetchone()[0]
265 if userid == 0:
266 raise aurweb.exceptions.InvalidUserException(user)
268 subprocess.Popen((notify_cmd, 'disown', str(pkgbase_id), str(userid)))
270 conn.close()
273 def pkgbase_flag(pkgbase, user, comment):
274 pkgbase_id = pkgbase_from_name(pkgbase)
275 if not pkgbase_id:
276 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
277 if len(comment) < 3:
278 raise aurweb.exceptions.InvalidCommentException(comment)
280 conn = aurweb.db.Connection()
282 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
283 userid = cur.fetchone()[0]
284 if userid == 0:
285 raise aurweb.exceptions.InvalidUserException(user)
287 now = int(time.time())
288 conn.execute("UPDATE PackageBases SET " +
289 "OutOfDateTS = ?, FlaggerUID = ?, FlaggerComment = ? " +
290 "WHERE ID = ? AND OutOfDateTS IS NULL",
291 [now, userid, comment, pkgbase_id])
293 conn.commit()
295 subprocess.Popen((notify_cmd, 'flag', str(userid), str(pkgbase_id)))
298 def pkgbase_unflag(pkgbase, user):
299 pkgbase_id = pkgbase_from_name(pkgbase)
300 if not pkgbase_id:
301 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
303 conn = aurweb.db.Connection()
305 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
306 userid = cur.fetchone()[0]
307 if userid == 0:
308 raise aurweb.exceptions.InvalidUserException(user)
310 if user in pkgbase_get_comaintainers(pkgbase):
311 conn.execute("UPDATE PackageBases SET OutOfDateTS = NULL " +
312 "WHERE ID = ?", [pkgbase_id])
313 else:
314 conn.execute("UPDATE PackageBases SET OutOfDateTS = NULL " +
315 "WHERE ID = ? AND (MaintainerUID = ? OR FlaggerUID = ?)",
316 [pkgbase_id, userid, userid])
318 conn.commit()
321 def pkgbase_vote(pkgbase, user):
322 pkgbase_id = pkgbase_from_name(pkgbase)
323 if not pkgbase_id:
324 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
326 conn = aurweb.db.Connection()
328 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
329 userid = cur.fetchone()[0]
330 if userid == 0:
331 raise aurweb.exceptions.InvalidUserException(user)
333 cur = conn.execute("SELECT COUNT(*) FROM PackageVotes " +
334 "WHERE UsersID = ? AND PackageBaseID = ?",
335 [userid, pkgbase_id])
336 if cur.fetchone()[0] > 0:
337 raise aurweb.exceptions.AlreadyVotedException(pkgbase)
339 now = int(time.time())
340 conn.execute("INSERT INTO PackageVotes (UsersID, PackageBaseID, VoteTS) " +
341 "VALUES (?, ?, ?)", [userid, pkgbase_id, now])
342 conn.execute("UPDATE PackageBases SET NumVotes = NumVotes + 1 " +
343 "WHERE ID = ?", [pkgbase_id])
344 conn.commit()
347 def pkgbase_unvote(pkgbase, user):
348 pkgbase_id = pkgbase_from_name(pkgbase)
349 if not pkgbase_id:
350 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
352 conn = aurweb.db.Connection()
354 cur = conn.execute("SELECT ID FROM Users WHERE Username = ?", [user])
355 userid = cur.fetchone()[0]
356 if userid == 0:
357 raise aurweb.exceptions.InvalidUserException(user)
359 cur = conn.execute("SELECT COUNT(*) FROM PackageVotes " +
360 "WHERE UsersID = ? AND PackageBaseID = ?",
361 [userid, pkgbase_id])
362 if cur.fetchone()[0] == 0:
363 raise aurweb.exceptions.NotVotedException(pkgbase)
365 conn.execute("DELETE FROM PackageVotes WHERE UsersID = ? AND " +
366 "PackageBaseID = ?", [userid, pkgbase_id])
367 conn.execute("UPDATE PackageBases SET NumVotes = NumVotes - 1 " +
368 "WHERE ID = ?", [pkgbase_id])
369 conn.commit()
372 def pkgbase_set_keywords(pkgbase, keywords):
373 pkgbase_id = pkgbase_from_name(pkgbase)
374 if not pkgbase_id:
375 raise aurweb.exceptions.InvalidPackageBaseException(pkgbase)
377 conn = aurweb.db.Connection()
379 conn.execute("DELETE FROM PackageKeywords WHERE PackageBaseID = ?",
380 [pkgbase_id])
381 for keyword in keywords:
382 conn.execute("INSERT INTO PackageKeywords (PackageBaseID, Keyword) " +
383 "VALUES (?, ?)", [pkgbase_id, keyword])
385 conn.commit()
386 conn.close()
389 def pkgbase_has_write_access(pkgbase, user):
390 conn = aurweb.db.Connection()
392 cur = conn.execute("SELECT COUNT(*) FROM PackageBases " +
393 "LEFT JOIN PackageComaintainers " +
394 "ON PackageComaintainers.PackageBaseID = PackageBases.ID " +
395 "INNER JOIN Users " +
396 "ON Users.ID = PackageBases.MaintainerUID " +
397 "OR PackageBases.MaintainerUID IS NULL " +
398 "OR Users.ID = PackageComaintainers.UsersID " +
399 "WHERE Name = ? AND Username = ?", [pkgbase, user])
400 return cur.fetchone()[0] > 0
403 def pkgbase_has_full_access(pkgbase, user):
404 conn = aurweb.db.Connection()
406 cur = conn.execute("SELECT COUNT(*) FROM PackageBases " +
407 "INNER JOIN Users " +
408 "ON Users.ID = PackageBases.MaintainerUID " +
409 "WHERE Name = ? AND Username = ?", [pkgbase, user])
410 return cur.fetchone()[0] > 0
413 def die(msg):
414 sys.stderr.write("{:s}\n".format(msg))
415 exit(1)
418 def die_with_help(msg):
419 die(msg + "\nTry `{:s} help` for a list of commands.".format(ssh_cmdline))
422 def warn(msg):
423 sys.stderr.write("warning: {:s}\n".format(msg))
426 def usage(cmds):
427 sys.stderr.write("Commands:\n")
428 colwidth = max([len(cmd) for cmd in cmds.keys()]) + 4
429 for key in sorted(cmds):
430 sys.stderr.write(" " + key.ljust(colwidth) + cmds[key] + "\n")
431 exit(0)
434 def checkarg_atleast(cmdargv, *argdesc):
435 if len(cmdargv) - 1 < len(argdesc):
436 msg = 'missing {:s}'.format(argdesc[len(cmdargv) - 1])
437 raise aurweb.exceptions.InvalidArgumentsException(msg)
440 def checkarg_atmost(cmdargv, *argdesc):
441 if len(cmdargv) - 1 > len(argdesc):
442 raise aurweb.exceptions.InvalidArgumentsException('too many arguments')
445 def checkarg(cmdargv, *argdesc):
446 checkarg_atleast(cmdargv, *argdesc)
447 checkarg_atmost(cmdargv, *argdesc)
450 def serve(action, cmdargv, user, privileged, remote_addr):
451 if enable_maintenance:
452 if remote_addr not in maintenance_exc:
453 raise aurweb.exceptions.MaintenanceException
455 if action == 'git' and cmdargv[1] in ('upload-pack', 'receive-pack'):
456 action = action + '-' + cmdargv[1]
457 del cmdargv[1]
459 if action == 'git-upload-pack' or action == 'git-receive-pack':
460 checkarg(cmdargv, 'path')
462 path = cmdargv[1].rstrip('/')
463 if not path.startswith('/'):
464 path = '/' + path
465 if not path.endswith('.git'):
466 path = path + '.git'
467 pkgbase = path[1:-4]
468 if not re.match(repo_regex, pkgbase):
469 raise aurweb.exceptions.InvalidRepositoryNameException(pkgbase)
471 if action == 'git-receive-pack' and pkgbase_exists(pkgbase):
472 if not privileged and not pkgbase_has_write_access(pkgbase, user):
473 raise aurweb.exceptions.PermissionDeniedException(user)
475 os.environ["AUR_USER"] = user
476 os.environ["AUR_PKGBASE"] = pkgbase
477 os.environ["GIT_NAMESPACE"] = pkgbase
478 cmd = action + " '" + repo_path + "'"
479 os.execl(git_shell_cmd, git_shell_cmd, '-c', cmd)
480 elif action == 'set-keywords':
481 checkarg(cmdargv, 'repository name')
482 pkgbase_set_keywords(cmdargv[1], cmdargv[2:])
483 elif action == 'list-repos':
484 checkarg(cmdargv)
485 list_repos(user)
486 elif action == 'setup-repo':
487 checkarg(cmdargv, 'repository name')
488 warn('{:s} is deprecated. '
489 'Use `git push` to create new repositories.'.format(action))
490 create_pkgbase(cmdargv[1], user)
491 elif action == 'restore':
492 checkarg(cmdargv, 'repository name')
494 pkgbase = cmdargv[1]
495 create_pkgbase(pkgbase, user)
497 os.environ["AUR_USER"] = user
498 os.environ["AUR_PKGBASE"] = pkgbase
499 os.execl(git_update_cmd, git_update_cmd, 'restore')
500 elif action == 'adopt':
501 checkarg(cmdargv, 'repository name')
503 pkgbase = cmdargv[1]
504 pkgbase_adopt(pkgbase, user, privileged)
505 elif action == 'disown':
506 checkarg(cmdargv, 'repository name')
508 pkgbase = cmdargv[1]
509 pkgbase_disown(pkgbase, user, privileged)
510 elif action == 'flag':
511 checkarg(cmdargv, 'repository name', 'comment')
513 pkgbase = cmdargv[1]
514 comment = cmdargv[2]
515 pkgbase_flag(pkgbase, user, comment)
516 elif action == 'unflag':
517 checkarg(cmdargv, 'repository name')
519 pkgbase = cmdargv[1]
520 pkgbase_unflag(pkgbase, user)
521 elif action == 'vote':
522 checkarg(cmdargv, 'repository name')
524 pkgbase = cmdargv[1]
525 pkgbase_vote(pkgbase, user)
526 elif action == 'unvote':
527 checkarg(cmdargv, 'repository name')
529 pkgbase = cmdargv[1]
530 pkgbase_unvote(pkgbase, user)
531 elif action == 'set-comaintainers':
532 checkarg_atleast(cmdargv, 'repository name')
534 pkgbase = cmdargv[1]
535 userlist = cmdargv[2:]
536 pkgbase_set_comaintainers(pkgbase, userlist, user, privileged)
537 elif action == 'help':
538 cmds = {
539 "adopt <name>": "Adopt a package base.",
540 "disown <name>": "Disown a package base.",
541 "flag <name> <comment>": "Flag a package base out-of-date.",
542 "help": "Show this help message and exit.",
543 "list-repos": "List all your repositories.",
544 "restore <name>": "Restore a deleted package base.",
545 "set-comaintainers <name> [...]": "Set package base co-maintainers.",
546 "set-keywords <name> [...]": "Change package base keywords.",
547 "setup-repo <name>": "Create a repository (deprecated).",
548 "unflag <name>": "Remove out-of-date flag from a package base.",
549 "unvote <name>": "Remove vote from a package base.",
550 "vote <name>": "Vote for a package base.",
551 "git-receive-pack": "Internal command used with Git.",
552 "git-upload-pack": "Internal command used with Git.",
554 usage(cmds)
555 else:
556 msg = 'invalid command: {:s}'.format(action)
557 raise aurweb.exceptions.InvalidArgumentsException(msg)
560 def main():
561 user = os.environ.get('AUR_USER')
562 privileged = (os.environ.get('AUR_PRIVILEGED', '0') == '1')
563 ssh_cmd = os.environ.get('SSH_ORIGINAL_COMMAND')
564 ssh_client = os.environ.get('SSH_CLIENT')
566 if not ssh_cmd:
567 die_with_help("Interactive shell is disabled.")
568 cmdargv = shlex.split(ssh_cmd)
569 action = cmdargv[0]
570 remote_addr = ssh_client.split(' ')[0] if ssh_client else None
572 try:
573 serve(action, cmdargv, user, privileged, remote_addr)
574 except aurweb.exceptions.MaintenanceException:
575 die("The AUR is down due to maintenance. We will be back soon.")
576 except aurweb.exceptions.InvalidArgumentsException as e:
577 die_with_help('{:s}: {}'.format(action, e))
578 except aurweb.exceptions.AurwebException as e:
579 die('{:s}: {}'.format(action, e))
582 if __name__ == '__main__':
583 main()