utils.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2017-2020 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import absolute_import, unicode_literals
  8. import datetime
  9. import fnmatch
  10. import logging
  11. import logging.config
  12. import os
  13. import re
  14. from functools import wraps
  15. import flask
  16. import pygit2
  17. import six
  18. import werkzeug.utils
  19. from six.moves.urllib.parse import urljoin, urlparse
  20. from pagure.config import config as pagure_config
  21. from pagure.exceptions import (
  22. InvalidDateformatException,
  23. InvalidTimestampException,
  24. PagureException,
  25. )
  26. _log = logging.getLogger(__name__)
  27. LOGGER_SETUP = False
  28. def set_up_logging(app=None, force=False, configkey="LOGGING"):
  29. global LOGGER_SETUP
  30. if LOGGER_SETUP and not force:
  31. _log.info("logging already setup")
  32. return
  33. logging.basicConfig()
  34. logging.config.dictConfig(pagure_config.get(configkey) or {"version": 1})
  35. LOGGER_SETUP = True
  36. def authenticated():
  37. """Utility function checking if the current user is logged in or not."""
  38. fas_user = None
  39. try:
  40. fas_user = flask.g.fas_user
  41. except (RuntimeError, AttributeError):
  42. pass
  43. return fas_user is not None
  44. def api_authenticated():
  45. """Utility function checking if the current user is logged in or not
  46. in the API.
  47. """
  48. return (
  49. hasattr(flask.g, "fas_user")
  50. and flask.g.fas_user is not None
  51. and hasattr(flask.g, "token")
  52. and flask.g.token is not None
  53. )
  54. def check_api_acls(acls, optional=False):
  55. """Checks if the user provided an API token with its request and if
  56. this token allows the user to access the endpoint desired.
  57. :arg acls: A list of access control
  58. :arg optional: Only check the API token is valid. Skip the ACL validation.
  59. """
  60. import pagure.api
  61. import pagure.lib.query
  62. if authenticated():
  63. return
  64. flask.g.token = None
  65. flask.g.fas_user = None
  66. token = None
  67. token_str = None
  68. if "Authorization" in flask.request.headers:
  69. authorization = flask.request.headers["Authorization"]
  70. if "token" in authorization:
  71. token_str = authorization.split("token", 1)[1].strip()
  72. token_auth = False
  73. error_msg = None
  74. if token_str:
  75. token = pagure.lib.query.get_api_token(flask.g.session, token_str)
  76. if token:
  77. if token.expired:
  78. error_msg = "Expired token"
  79. else:
  80. flask.g.authenticated = True
  81. # Some ACLs are required
  82. if acls:
  83. token_acls_set = set(token.acls_list)
  84. needed_acls_set = set(acls or [])
  85. overlap = token_acls_set.intersection(needed_acls_set)
  86. # Our token has some of the required ACLs: auth successful
  87. if overlap:
  88. token_auth = True
  89. flask.g.fas_user = token.user
  90. # To get a token, in the `fas` auth user must have
  91. # signed the CLA, so just set it to True
  92. flask.g.fas_user.cla_done = True
  93. flask.g.token = token
  94. flask.g.authenticated = True
  95. # Our token has none of the required ACLs -> auth fail
  96. else:
  97. error_msg = "Missing ACLs: %s" % ", ".join(
  98. sorted(set(acls) - set(token.acls_list))
  99. )
  100. # No ACL required
  101. else:
  102. if optional:
  103. token_auth = True
  104. flask.g.fas_user = token.user
  105. # To get a token, in the `fas` auth user must have
  106. # signed the CLA, so just set it to True
  107. flask.g.fas_user.cla_done = True
  108. flask.g.token = token
  109. flask.g.authenticated = True
  110. else:
  111. error_msg = "Invalid token"
  112. elif optional:
  113. return
  114. else:
  115. error_msg = "Invalid token"
  116. if not token_auth:
  117. output = {
  118. "error_code": pagure.api.APIERROR.EINVALIDTOK.name,
  119. "error": pagure.api.APIERROR.EINVALIDTOK.value,
  120. "errors": error_msg,
  121. }
  122. jsonout = flask.jsonify(output)
  123. jsonout.status_code = 401
  124. return jsonout
  125. def is_safe_url(target): # pragma: no cover
  126. """Checks that the target url is safe and sending to the current
  127. website not some other malicious one.
  128. """
  129. ref_url = urlparse(flask.request.host_url)
  130. test_url = urlparse(urljoin(flask.request.host_url, target))
  131. return (
  132. target is not None
  133. and test_url.scheme in ("http", "https")
  134. and ref_url.netloc == test_url.netloc
  135. )
  136. def is_admin():
  137. """Return whether the user is admin for this application or not."""
  138. if not authenticated():
  139. return False
  140. user = flask.g.fas_user
  141. auth_method = pagure_config.get("PAGURE_AUTH", None)
  142. if auth_method == "fas":
  143. if not user.cla_done:
  144. return False
  145. admin_users = pagure_config.get("PAGURE_ADMIN_USERS", [])
  146. if not isinstance(admin_users, list):
  147. admin_users = [admin_users]
  148. if user.username in admin_users:
  149. return True
  150. admins = pagure_config["ADMIN_GROUP"]
  151. if not isinstance(admins, list):
  152. admins = [admins]
  153. admins = set(admins or [])
  154. groups = set(flask.g.fas_user.groups)
  155. return not groups.isdisjoint(admins)
  156. def is_repo_admin(repo_obj, username=None):
  157. """Return whether the user is an admin of the provided repo."""
  158. if not authenticated():
  159. return False
  160. if username:
  161. user = username
  162. else:
  163. user = flask.g.fas_user.username
  164. if is_admin():
  165. return True
  166. usergrps = [usr.user for grp in repo_obj.admin_groups for usr in grp.users]
  167. return (
  168. user == repo_obj.user.user
  169. or (user in [usr.user for usr in repo_obj.admins])
  170. or (user in usergrps)
  171. )
  172. def is_repo_committer(repo_obj, username=None, session=None):
  173. """Return whether the user is a committer of the provided repo."""
  174. import pagure.lib.query
  175. usergroups = set()
  176. if username is None:
  177. if not authenticated():
  178. return False
  179. if is_admin():
  180. return True
  181. username = flask.g.fas_user.username
  182. usergroups = set(flask.g.fas_user.groups)
  183. if not session:
  184. session = flask.g.session
  185. try:
  186. user = pagure.lib.query.get_user(session, username)
  187. usergroups = usergroups.union(set(user.groups))
  188. except pagure.exceptions.PagureException:
  189. return False
  190. # If the user is main admin -> yep
  191. if repo_obj.user.user == username:
  192. return True
  193. # If they are in the list of committers -> yep
  194. for user in repo_obj.committers:
  195. if user.user == username:
  196. return True
  197. # If they are in a group that has commit access -> yep
  198. for group in repo_obj.committer_groups:
  199. if group.group_name in usergroups:
  200. return True
  201. # If no direct committer, check EXTERNAL_COMMITTER info
  202. ext_committer = pagure_config.get("EXTERNAL_COMMITTER", None)
  203. if ext_committer:
  204. overlap = set(ext_committer) & usergroups
  205. if overlap:
  206. for grp in overlap:
  207. restrict = ext_committer[grp].get("restrict", [])
  208. exclude = ext_committer[grp].get("exclude", [])
  209. if restrict and repo_obj.fullname not in restrict:
  210. continue
  211. elif repo_obj.fullname in exclude:
  212. continue
  213. else:
  214. return True
  215. # The user is not in an external_committer group that grants access, and
  216. # not a direct committer -> You have no power here
  217. return False
  218. def is_repo_collaborator(repo_obj, refname, username=None, session=None):
  219. """Return whether the user has commit on the specified branch of the
  220. provided repo."""
  221. committer = is_repo_committer(repo_obj, username=username, session=session)
  222. if committer:
  223. _log.debug("User is a committer")
  224. return committer
  225. import pagure.lib.query
  226. if username is None:
  227. if not authenticated():
  228. return False
  229. if is_admin():
  230. return True
  231. username = flask.g.fas_user.username
  232. usergroups = set(flask.g.fas_user.groups)
  233. if not session:
  234. session = flask.g.session
  235. try:
  236. user = pagure.lib.query.get_user(session, username)
  237. usergroups = set(user.groups)
  238. except pagure.exceptions.PagureException:
  239. return False
  240. # If they are in the list of committers -> maybe
  241. for user in repo_obj.collaborators:
  242. if user.user.username == username:
  243. # if branch is None when the user tries to read,
  244. # so we'll allow that
  245. if refname is None:
  246. return True
  247. # If the branch is specified: the user is trying to write, we'll
  248. # check if they are allowed to
  249. for pattern in user.branches.split(","):
  250. pattern = "refs/heads/{}".format(pattern.strip())
  251. if fnmatch.fnmatch(refname, pattern):
  252. return True
  253. # If they are in a group that has commit access -> maybe
  254. for project_group in repo_obj.collaborator_project_groups:
  255. if project_group.group.group_name in usergroups:
  256. # if branch is None when the user tries to read,
  257. # so we'll allow that
  258. if refname is None:
  259. return True
  260. # If the branch is specified: the user is trying to write, we'll
  261. # check if they are allowed to
  262. for pattern in project_group.branches.split(","):
  263. pattern = "refs/heads/{}".format(pattern.strip())
  264. if fnmatch.fnmatch(refname, pattern):
  265. return True
  266. return False
  267. def is_repo_user(repo_obj, username=None):
  268. """Return whether the user has some access in the provided repo."""
  269. if username:
  270. user = username
  271. else:
  272. if not authenticated():
  273. return False
  274. user = flask.g.fas_user.username
  275. if is_admin():
  276. return True
  277. usergrps = [usr.user for grp in repo_obj.groups for usr in grp.users]
  278. return (
  279. user == repo_obj.user.user
  280. or (user in [usr.user for usr in repo_obj.users])
  281. or (user in usergrps)
  282. )
  283. def get_user_repo_access(repo_obj, username):
  284. """return a string of the highest level of access
  285. a user has on a repo.
  286. """
  287. if repo_obj.user.username == username:
  288. return "main admin"
  289. if is_repo_admin(repo_obj, username):
  290. return "admin"
  291. if is_repo_committer(repo_obj, username):
  292. return "commit"
  293. if is_repo_user(repo_obj, username):
  294. return "ticket"
  295. return None
  296. def login_required(function):
  297. """Flask decorator to retrict access to logged in user.
  298. If the auth system is ``fas`` it will also require that the user sign
  299. the FPCA.
  300. """
  301. @wraps(function)
  302. def decorated_function(*args, **kwargs):
  303. """Decorated function, actually does the work."""
  304. auth_method = pagure_config.get("PAGURE_AUTH", None)
  305. if flask.session.get("_justloggedout", False):
  306. return flask.redirect(flask.url_for("ui_ns.index"))
  307. elif not authenticated():
  308. return flask.redirect(
  309. flask.url_for("auth_login", next=flask.request.url)
  310. )
  311. elif auth_method == "fas" and not flask.g.fas_user.cla_done:
  312. flask.session["_requires_fpca"] = True
  313. flask.flash(
  314. flask.Markup(
  315. 'You must <a href="https://accounts.fedoraproject'
  316. '.org/">sign the FPCA</a> (Fedora Project '
  317. "Contributor Agreement) to use pagure"
  318. ),
  319. "errors",
  320. )
  321. return flask.redirect(flask.url_for("ui_ns.index"))
  322. return function(*args, **kwargs)
  323. return decorated_function
  324. def __get_file_in_tree(repo_obj, tree, filepath, bail_on_tree=False):
  325. """Retrieve the entry corresponding to the provided filename in a
  326. given tree.
  327. """
  328. filename = filepath[0]
  329. if isinstance(tree, pygit2.Blob):
  330. return
  331. for entry in tree:
  332. fname = entry.name
  333. if six.PY2:
  334. fname = entry.name.decode("utf-8")
  335. if fname == filename:
  336. if len(filepath) == 1:
  337. blob = repo_obj.get(entry.id)
  338. # If we can't get the content (for example: an empty folder)
  339. if blob is None:
  340. return
  341. # If we get a tree instead of a blob, let's escape
  342. if isinstance(blob, pygit2.Tree) and bail_on_tree:
  343. return blob
  344. content = blob.data
  345. # If it's a (sane) symlink, we try a single-level dereference
  346. if (
  347. entry.filemode == pygit2.GIT_FILEMODE_LINK
  348. and os.path.normpath(content) == content
  349. and not os.path.isabs(content)
  350. ):
  351. try:
  352. dereferenced = tree[content]
  353. except KeyError:
  354. pass
  355. else:
  356. if dereferenced.filemode == pygit2.GIT_FILEMODE_BLOB:
  357. blob = repo_obj[dereferenced.oid]
  358. return blob
  359. else:
  360. try:
  361. nextitem = repo_obj[entry.oid]
  362. except KeyError:
  363. # We could not find the blob/entry in the git repo
  364. # so we bail
  365. return
  366. # If we can't get the content (for example: an empty folder)
  367. if nextitem is None:
  368. return
  369. return __get_file_in_tree(
  370. repo_obj, nextitem, filepath[1:], bail_on_tree=bail_on_tree
  371. )
  372. ip_middle_octet = r"(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5]))"
  373. ip_last_octet = r"(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))"
  374. """
  375. regex based on https://github.com/kvesteri/validators/blob/
  376. master/validators/url.py
  377. LICENSED on Dec 16th 2016 as MIT:
  378. The MIT License (MIT)
  379. Copyright (c) 2013-2014 Konsta Vesterinen
  380. Permission is hereby granted, free of charge, to any person obtaining a
  381. copy of this software and associated documentation files (the "Software"),
  382. to deal in the Software without restriction, including without limitation
  383. the rights to use, copy, modify, merge, publish, distribute, sublicense,
  384. and/or sell copies of the Software, and to permit persons to whom the
  385. Software is furnished to do so, subject to the following conditions:
  386. The above copyright notice and this permission notice shall be included in
  387. all copies or substantial portions of the Software.
  388. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  389. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  390. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  391. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  392. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
  393. FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
  394. IN THE SOFTWARE.
  395. """
  396. urlregex = re.compile(
  397. "^"
  398. # protocol identifier
  399. r"(?:(?:https?|ftp|git)://)"
  400. # user:pass authentication
  401. "(?:[-a-z\u00a1-\uffff0-9._~%!$&'()*+,;=:]+"
  402. "(?::[-a-z0-9._~%!$&'()*+,;=:]*)?@)?"
  403. "(?:"
  404. "(?P<private_ip>"
  405. # IP address exclusion
  406. # private & local networks
  407. "(?:(?:10|127)" + ip_middle_octet + "{2}" + ip_last_octet + ")|"
  408. r"(?:(?:169\.254|192\.168)" + ip_middle_octet + ip_last_octet + ")|"
  409. r"(?:172\.(?:1[6-9]|2\d|3[0-1])" + ip_middle_octet + ip_last_octet + "))"
  410. "|"
  411. # private & local hosts
  412. "(?P<private_host>" "(?:localhost))" "|"
  413. # IP address dotted notation octets
  414. # excludes loopback network 0.0.0.0
  415. # excludes reserved space >= 224.0.0.0
  416. # excludes network & broadcast addresses
  417. # (first & last IP address of each class)
  418. "(?P<public_ip>"
  419. r"(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])"
  420. "" + ip_middle_octet + "{2}"
  421. "" + ip_last_octet + ")"
  422. "|"
  423. # IPv6 RegEx from https://stackoverflow.com/a/17871737
  424. r"\[("
  425. # 1:2:3:4:5:6:7:8
  426. "([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|"
  427. # 1:: 1:2:3:4:5:6:7::
  428. "([0-9a-fA-F]{1,4}:){1,7}:|"
  429. # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8
  430. "([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|"
  431. # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8
  432. "([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|"
  433. # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8
  434. "([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|"
  435. # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8
  436. "([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|"
  437. # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8
  438. "([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|"
  439. # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8
  440. "[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|"
  441. # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::
  442. ":((:[0-9a-fA-F]{1,4}){1,7}|:)|"
  443. # fe80::7:8%eth0 fe80::7:8%1
  444. # (link-local IPv6 addresses with zone index)
  445. "fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|"
  446. "::(ffff(:0{1,4}){0,1}:){0,1}"
  447. r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"
  448. # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255
  449. # (IPv4-mapped IPv6 addresses and IPv4-translated addresses)
  450. "(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|"
  451. "([0-9a-fA-F]{1,4}:){1,4}:"
  452. r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"
  453. # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33
  454. # (IPv4-Embedded IPv6 Address)
  455. "(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])" r")\]|"
  456. # host name
  457. "(?:(?:[a-z\u00a1-\uffff0-9]-?)*[a-z\u00a1-\uffff0-9]+)"
  458. # domain name
  459. r"(?:\.(?:[a-z\u00a1-\uffff0-9]-?)*[a-z\u00a1-\uffff0-9]+)*"
  460. # TLD identifier
  461. r"(?:\.(?:[a-z\u00a1-\uffff]{2,}))" ")"
  462. # port number
  463. r"(?::\d{2,5})?"
  464. # resource path
  465. r"(?:/[-a-z\u00a1-\uffff0-9._~%!$&'()*+,;=:@/]*)?"
  466. # query string
  467. r"(?:\?\S*)?"
  468. # fragment
  469. r"(?:#\S*)?" "$",
  470. re.UNICODE | re.IGNORECASE,
  471. )
  472. urlpattern = re.compile(urlregex)
  473. ssh_urlregex = re.compile(
  474. "^"
  475. # protocol identifier
  476. r"(?:(?:ssh|git\+ssh)://)?"
  477. # user@ authentication
  478. "[-a-z\u00a1-\uffff0-9._~%!$&'()*+,;=:]+@"
  479. # Opening section about host
  480. "(?:"
  481. # IP address exclusion
  482. "(?P<private_ip>"
  483. # private & local networks
  484. "(?:(?:10|127)" + ip_middle_octet + "{2}" + ip_last_octet + ")|"
  485. r"(?:(?:169\.254|192\.168)" + ip_middle_octet + ip_last_octet + ")|"
  486. r"(?:172\.(?:1[6-9]|2\d|3[0-1])" + ip_middle_octet + ip_last_octet + "))"
  487. "|"
  488. # private & local hosts
  489. "(?P<private_host>" "(?:localhost))" "|"
  490. # IP address dotted notation octets
  491. # excludes loopback network 0.0.0.0
  492. # excludes reserved space >= 224.0.0.0
  493. # excludes network & broadcast addresses
  494. # (first & last IP address of each class)
  495. "(?P<public_ip>"
  496. r"(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])"
  497. "" + ip_middle_octet + "{2}"
  498. "" + ip_last_octet + ")"
  499. "|"
  500. # IPv6 RegEx from https://stackoverflow.com/a/17871737
  501. r"\[("
  502. # 1:2:3:4:5:6:7:8
  503. "([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|"
  504. # 1:: 1:2:3:4:5:6:7::
  505. "([0-9a-fA-F]{1,4}:){1,7}:|"
  506. # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8
  507. "([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|"
  508. # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8
  509. "([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|"
  510. # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8
  511. "([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|"
  512. # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8
  513. "([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|"
  514. # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8
  515. "([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|"
  516. # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8
  517. "[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|"
  518. # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::
  519. ":((:[0-9a-fA-F]{1,4}){1,7}|:)|"
  520. # fe80::7:8%eth0 fe80::7:8%1
  521. # (link-local IPv6 addresses with zone index)
  522. "fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|"
  523. "::(ffff(:0{1,4}){0,1}:){0,1}"
  524. r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"
  525. # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255
  526. # (IPv4-mapped IPv6 addresses and IPv4-translated addresses)
  527. "(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|"
  528. "([0-9a-fA-F]{1,4}:){1,4}:"
  529. r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"
  530. # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33
  531. # (IPv4-Embedded IPv6 Address)
  532. r"(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])" r")\]|"
  533. # host name
  534. r"(?:(?:[a-z\u00a1-\uffff0-9]-?)*[a-z\u00a1-\uffff0-9]+)"
  535. # domain name
  536. r"(?:\.(?:[a-z\u00a1-\uffff0-9]-?)*[a-z\u00a1-\uffff0-9]+)*"
  537. # TLD identifier
  538. r"(?:\.(?:[a-z\u00a1-\uffff]{2,}))"
  539. # Closing the entire section about host
  540. ")"
  541. # port number
  542. r"(?::\d{2,5})?"
  543. # resource path
  544. r"(?:[:/][-a-z\u00a1-\uffff0-9._~%!$&'()*+,;=:@/]*)?"
  545. # query string
  546. r"(?:\?\S*)?"
  547. # fragment
  548. r"(?:#\S*)?" "$",
  549. re.UNICODE | re.IGNORECASE,
  550. )
  551. ssh_urlpattern = re.compile(ssh_urlregex)
  552. def get_repo_path(repo):
  553. """Return the path of the git repository corresponding to the provided
  554. Repository object from the DB.
  555. """
  556. repopath = repo.repopath("main")
  557. if not os.path.exists(repopath):
  558. _log.debug("Git repo not found at: %s", repopath)
  559. flask.abort(404, description="No git repo found")
  560. return repopath
  561. def get_remote_repo_path(remote_git, branch_from, ignore_non_exist=False):
  562. """Return the path of the remote git repository corresponding to the
  563. provided information.
  564. """
  565. repopath = os.path.join(
  566. pagure_config["REMOTE_GIT_FOLDER"],
  567. werkzeug.utils.secure_filename("%s_%s" % (remote_git, branch_from)),
  568. )
  569. if not os.path.exists(repopath) and not ignore_non_exist:
  570. return None
  571. else:
  572. return repopath
  573. def get_task_redirect_url(task, prev):
  574. if not task.ready():
  575. return flask.url_for("ui_ns.wait_task", taskid=task.id, prev=prev)
  576. result = task.get(timeout=0, propagate=False)
  577. if task.failed():
  578. flask.flash("Your task failed: %s" % result)
  579. task.forget()
  580. return prev
  581. if isinstance(result, dict):
  582. endpoint = result.pop("endpoint")
  583. task.forget()
  584. return flask.url_for(endpoint, **result)
  585. else:
  586. task.forget()
  587. flask.abort(418)
  588. def wait_for_task(task, prev=None):
  589. if prev is None:
  590. prev = flask.request.full_path
  591. elif not is_safe_url(prev):
  592. prev = flask.url_for("index")
  593. return flask.redirect(get_task_redirect_url(task, prev))
  594. def wait_for_task_post(taskid, form, endpoint, initial=False, **kwargs):
  595. form_action = flask.url_for(endpoint, **kwargs)
  596. return flask.render_template(
  597. "waiting_post.html",
  598. taskid=taskid,
  599. form_action=form_action,
  600. form_data=form.data,
  601. csrf=form.csrf_token,
  602. initial=initial,
  603. )
  604. def split_project_fullname(project_name):
  605. """Returns the user, namespace and
  606. project name from a project fullname"""
  607. user = None
  608. namespace = None
  609. if "/" in project_name:
  610. project_items = project_name.split("/")
  611. if len(project_items) == 2:
  612. namespace, project_name = project_items
  613. elif len(project_items) == 3:
  614. _, user, project_name = project_items
  615. elif len(project_items) == 4:
  616. _, user, namespace, project_name = project_items
  617. return (user, namespace, project_name)
  618. def get_parent_repo_path(repo, repotype="main"):
  619. """Return the path of the parent git repository corresponding to the
  620. provided Repository object from the DB.
  621. """
  622. if repo.parent:
  623. return repo.parent.repopath(repotype)
  624. else:
  625. return repo.repopath(repotype)
  626. def stream_template(app, template_name, **context):
  627. app.update_template_context(context)
  628. t = app.jinja_env.get_template(template_name)
  629. rv = t.stream(context)
  630. rv.enable_buffering(5)
  631. return rv
  632. def is_true(value, trueish=("1", "true", "t", "y")):
  633. if isinstance(value, bool):
  634. return value
  635. if isinstance(value, six.binary_type):
  636. # In Py3, str(b'true') == "b'true'", not b'true' as in Py2.
  637. value = value.decode()
  638. else:
  639. value = str(value)
  640. return value.strip().lower() in trueish
  641. def validate_date(input_date, allow_empty=False):
  642. """Validate a given time.
  643. The time can either be given as an unix timestamp or using the
  644. yyyy-mm-dd format.
  645. If either fail to parse, we raise a 400 error
  646. """
  647. if allow_empty and input_date == "":
  648. return None
  649. # Validate and convert the time
  650. if input_date.isdigit():
  651. # We assume its a timestamp, so convert it to datetime
  652. try:
  653. output_date = datetime.datetime.fromtimestamp(int(input_date))
  654. except ValueError:
  655. raise InvalidTimestampException()
  656. else:
  657. # We assume datetime format, so validate it
  658. try:
  659. output_date = datetime.datetime.strptime(input_date, "%Y-%m-%d")
  660. except ValueError:
  661. raise InvalidDateformatException()
  662. return output_date
  663. def validate_date_range(value):
  664. """Validate a given date range specified using the format since..until.
  665. If .. is not present in the range, it is assumed that only since was
  666. provided.
  667. """
  668. since = until = None
  669. if value is not None:
  670. if ".." in value:
  671. since, _, until = value.partition("..")
  672. else:
  673. since = value
  674. if since is not None:
  675. since = validate_date(since, allow_empty=True)
  676. if until is not None:
  677. until = validate_date(until, allow_empty=True)
  678. return (since, until)
  679. def get_merge_options(request, merge_status):
  680. MERGE_OPTIONS = {
  681. "NO_CHANGE": {
  682. "code": "NO_CHANGE",
  683. "short_code": "No changes",
  684. "message": "Nothing to change, git is up to date",
  685. },
  686. "FFORWARD": {
  687. "code": "FFORWARD",
  688. "short_code": "Ok",
  689. "message": "The pull-request can be merged and fast-forwarded",
  690. },
  691. "CONFLICTS": {
  692. "code": "CONFLICTS",
  693. "short_code": "Conflicts",
  694. "message": "The pull-request cannot be merged due to conflicts",
  695. },
  696. "MERGE-non-ff-ok": {
  697. "code": "MERGE",
  698. "short_code": "With merge",
  699. "message": "The pull-request can be merged with a merge commit",
  700. },
  701. "MERGE-non-ff-bad": {
  702. "code": "NEEDSREBASE",
  703. "short_code": "Needs rebase",
  704. "message": "The pull-request must be rebased before merging",
  705. },
  706. }
  707. if merge_status == "MERGE":
  708. if request.project.settings.get(
  709. "disable_non_fast-forward_merges", False
  710. ):
  711. merge_status += "-non-ff-bad"
  712. else:
  713. merge_status += "-non-ff-ok"
  714. return MERGE_OPTIONS[merge_status]
  715. def lookup_deploykey(project, username):
  716. """Finds the Deploy Key specified by the username.
  717. Args:
  718. project (model.Project): The project to look in
  719. username (string): The username string provided for the deploy key
  720. Returns (model.SSHKey or None): The SSHKey instance representing the
  721. project-specific deploy key by the username. None if the username is
  722. not a deploykey username or is not a valid deploy key for project.
  723. """
  724. # The username to look for is: deploykey_(filename(project.fullname))_keyid
  725. if not username.startswith("deploykey_"):
  726. return None
  727. username = username[len("deploykey_") :]
  728. rest, keyid = username.rsplit("_", 1)
  729. if rest != werkzeug.utils.secure_filename(project.fullname):
  730. # This is not a deploykey for the specified project
  731. return None
  732. keyid = int(keyid)
  733. for key in project.deploykeys:
  734. if key.id == keyid:
  735. return key
  736. return None
  737. def project_has_hook_attr_value(project, hook, attr, value):
  738. """Finds out if project's hook has attribute of given value.
  739. :arg project: The project to inspect
  740. :type project: pagure.lib.model.Project
  741. :arg hook: Name of the hook to inspect
  742. :type hook: str
  743. :arg attr: Name of hook attribute to inspect
  744. :type attr: str
  745. :arg value: Value to compare project's hook attribute value with
  746. :type value: object
  747. :return: True if project's hook attribute value is equal with given
  748. value, False otherwise
  749. """
  750. retval = False
  751. hook_obj = getattr(project, hook, None)
  752. if hook_obj is not None:
  753. attr_obj = getattr(hook_obj, attr, None)
  754. if attr_obj == value:
  755. retval = True
  756. return retval
  757. def parse_path(path):
  758. """Get the repo name, object type, object ID, and (if present)
  759. username and/or namespace from a URL path component. Will only
  760. handle the known object types from the OBJECTS dict. Assumes:
  761. * Project name comes immediately before object type
  762. * Object ID comes immediately after object type
  763. * If a fork, path starts with /fork/(username)
  764. * Namespace, if present, comes after fork username (if present) or at start
  765. * No other components come before the project name
  766. * None of the parsed items can contain a /
  767. """
  768. username = None
  769. namespace = None
  770. # path always starts with / so split and throw away first item
  771. items = path.split("/")[1:]
  772. # find the *last* match for any object type
  773. try:
  774. objtype = [
  775. item for item in items if item in ["issue", "pull-request"]
  776. ][-1]
  777. except IndexError:
  778. raise PagureException("No known object type found in path: %s" % path)
  779. try:
  780. # objid is the item after objtype, we need all items up to it
  781. items = items[: items.index(objtype) + 2]
  782. # now strip the repo, objtype and objid off the end
  783. (repo, objtype, objid) = items[-3:]
  784. items = items[:-3]
  785. except (IndexError, ValueError):
  786. raise PagureException(
  787. "No project or object ID found in path: %s" % path
  788. )
  789. # now check for a fork
  790. if items and items[0] == "fork":
  791. try:
  792. # get the username and strip it and 'fork'
  793. username = items[1]
  794. items = items[2:]
  795. except IndexError:
  796. raise PagureException(
  797. "Path starts with /fork but no user found! Path: %s" % path
  798. )
  799. # if we still have an item left, it must be the namespace
  800. if items:
  801. namespace = items.pop(0)
  802. # if we have any items left at this point, we've no idea
  803. if items:
  804. raise PagureException(
  805. "More path components than expected! Path: %s" % path
  806. )
  807. return username, namespace, repo, objtype, objid