blob: b7199b1aa3bf200a76c126cc594f8b2ecf73846e [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Helper functions and classes used throughout Monorail."""
7
8from __future__ import division
9from __future__ import print_function
10from __future__ import absolute_import
11
12import collections
13import logging
14import random
15import string
16import textwrap
17import threading
18import time
19import traceback
20import urllib
21import urlparse
22
23from google.appengine.api import app_identity
24
25import ezt
26import six
27
28import settings
29from framework import framework_bizobj
30from framework import framework_constants
31from framework import template_helpers
32from framework import timestr
33from framework import urls
34from proto import user_pb2
35from services import client_config_svc
36
37# AttachmentUpload holds the information of an incoming uploaded
38# attachment before it gets saved as a gcs file and saved to the DB.
39AttachmentUpload = collections.namedtuple(
40 'AttachmentUpload', ['filename', 'contents', 'mimetype'])
41# type: (str, str, str) -> None
42
43# For random key generation
44RANDOM_KEY_LENGTH = 128
45RANDOM_KEY_CHARACTERS = string.ascii_letters + string.digits
46
47# params recognized by FormatURL, in the order they will appear in the url
48RECOGNIZED_PARAMS = ['can', 'start', 'num', 'q', 'colspec', 'groupby', 'sort',
49 'show', 'format', 'me', 'table_title', 'projects',
50 'hotlist_id']
51
52
53def retry(tries, delay=1, backoff=2):
54 """A retry decorator with exponential backoff.
55
56 Functions are retried when Exceptions occur.
57
58 Args:
59 tries: int Number of times to retry, set to 0 to disable retry.
60 delay: float Initial sleep time in seconds.
61 backoff: float Must be greater than 1, further failures would sleep
62 delay*=backoff seconds.
63 """
64 if backoff <= 1:
65 raise ValueError("backoff must be greater than 1")
66 if tries < 0:
67 raise ValueError("tries must be 0 or greater")
68 if delay <= 0:
69 raise ValueError("delay must be greater than 0")
70
71 def decorator(func):
72 def wrapper(*args, **kwargs):
73 _tries, _delay = tries, delay
74 _tries += 1 # Ensure we call func at least once.
75 while _tries > 0:
76 try:
77 ret = func(*args, **kwargs)
78 return ret
79 except Exception:
80 _tries -= 1
81 if _tries == 0:
82 logging.error('Exceeded maximum number of retries for %s.',
83 func.__name__)
84 raise
85 trace_str = traceback.format_exc()
86 logging.warning('Retrying %s due to Exception: %s',
87 func.__name__, trace_str)
88 time.sleep(_delay)
89 _delay *= backoff # Wait longer the next time we fail.
90 return wrapper
91 return decorator
92
93
94class PromiseCallback(object):
95 """Executes the work of a Promise and then dereferences everything."""
96
97 def __init__(self, promise, callback, *args, **kwargs):
98 self.promise = promise
99 self.callback = callback
100 self.args = args
101 self.kwargs = kwargs
102
103 def __call__(self):
104 try:
105 self.promise._WorkOnPromise(self.callback, *self.args, **self.kwargs)
106 finally:
107 # Make sure we no longer hold onto references to anything.
108 self.promise = self.callback = self.args = self.kwargs = None
109
110
111class Promise(object):
112 """Class for promises to deliver a value in the future.
113
114 A thread is started to run callback(args), that thread
115 should return the value that it generates, or raise an expception.
116 p.WaitAndGetValue() will block until a value is available.
117 If an exception was raised, p.WaitAndGetValue() will re-raise the
118 same exception.
119 """
120
121 def __init__(self, callback, *args, **kwargs):
122 """Initialize the promise and immediately call the supplied function.
123
124 Args:
125 callback: Function that takes the args and returns the promise value.
126 *args: Any arguments to the target function.
127 **kwargs: Any keyword args for the target function.
128 """
129
130 self.has_value = False
131 self.value = None
132 self.event = threading.Event()
133 self.exception = None
134
135 promise_callback = PromiseCallback(self, callback, *args, **kwargs)
136
137 # Execute the callback in another thread.
138 promise_thread = threading.Thread(target=promise_callback)
139 promise_thread.start()
140
141 def _WorkOnPromise(self, callback, *args, **kwargs):
142 """Run callback to compute the promised value. Save any exceptions."""
143 try:
144 self.value = callback(*args, **kwargs)
145 except Exception as e:
146 trace_str = traceback.format_exc()
147 logging.info('Exception while working on promise: %s\n', trace_str)
148 # Add the stack trace at this point to the exception. That way, in the
149 # logs, we can see what happened further up in the call stack
150 # than WaitAndGetValue(), which re-raises exceptions.
151 e.pre_promise_trace = trace_str
152 self.exception = e
153 finally:
154 self.has_value = True
155 self.event.set()
156
157 def WaitAndGetValue(self):
158 """Block until my value is available, then return it or raise exception."""
159 self.event.wait()
160 if self.exception:
161 raise self.exception # pylint: disable=raising-bad-type
162 return self.value
163
164
165def FormatAbsoluteURLForDomain(
166 host, project_name, servlet_name, scheme='https', **kwargs):
167 """A variant of FormatAbsoluteURL for when request objects are not available.
168
169 Args:
170 host: string with hostname and optional port, e.g. 'localhost:8080'.
171 project_name: the destination project name, if any.
172 servlet_name: site or project-local url fragement of dest page.
173 scheme: url scheme, e.g., 'http' or 'https'.
174 **kwargs: additional query string parameters may be specified as named
175 arguments to this function.
176
177 Returns:
178 A full url beginning with 'http[s]://'.
179 """
180 path_and_args = FormatURL(None, servlet_name, **kwargs)
181
182 if host:
183 domain_port = host.split(':')
184 domain_port[0] = GetPreferredDomain(domain_port[0])
185 host = ':'.join(domain_port)
186
187 absolute_domain_url = '%s://%s' % (scheme, host)
188 if project_name:
189 return '%s/p/%s%s' % (absolute_domain_url, project_name, path_and_args)
190 return absolute_domain_url + path_and_args
191
192
193def FormatAbsoluteURL(
194 mr, servlet_name, include_project=True, project_name=None,
195 scheme=None, copy_params=True, **kwargs):
196 """Return an absolute URL to a servlet with old and new params.
197
198 Args:
199 mr: info parsed from the current request.
200 servlet_name: site or project-local url fragement of dest page.
201 include_project: if True, include the project home url as part of the
202 destination URL (as long as it is specified either in mr
203 or as the project_name param.)
204 project_name: the destination project name, to override
205 mr.project_name if include_project is True.
206 scheme: either 'http' or 'https', to override mr.request.scheme.
207 copy_params: if True, copy well-known parameters from the existing request.
208 **kwargs: additional query string parameters may be specified as named
209 arguments to this function.
210
211 Returns:
212 A full url beginning with 'http[s]://'. The destination URL will be in
213 the same domain as the current request.
214 """
215 path_and_args = FormatURL(
216 [(name, mr.GetParam(name)) for name in RECOGNIZED_PARAMS]
217 if copy_params else None,
218 servlet_name, **kwargs)
219 scheme = scheme or mr.request.scheme
220
221 project_base = ''
222 if include_project:
223 project_base = '/p/%s' % (project_name or mr.project_name)
224
225 return '%s://%s%s%s' % (scheme, mr.request.host, project_base, path_and_args)
226
227
228def FormatMovedProjectURL(mr, moved_to):
229 """Return a transformation of the given url into the given project.
230
231 Args:
232 mr: common information parsed from the HTTP request.
233 moved_to: A string from a project's moved_to field that matches
234 project_constants.RE_PROJECT_NAME.
235
236 Returns:
237 The url transposed into the given destination project.
238 """
239 project_name = moved_to
240 _, _, path, parameters, query, fragment_identifier = urlparse.urlparse(
241 mr.current_page_url)
242 # Strip off leading "/p/<moved from project>"
243 path = '/' + path.split('/', 3)[3]
244 rest_of_url = urlparse.urlunparse(
245 ('', '', path, parameters, query, fragment_identifier))
246 return '/p/%s%s' % (project_name, rest_of_url)
247
248
249def GetNeededDomain(project_name, current_domain):
250 """Return the branded domain for the project iff not on current_domain."""
251 if (not current_domain or
252 '.appspot.com' in current_domain or
253 ':' in current_domain):
254 return None
255 desired_domain = settings.branded_domains.get(
256 project_name, settings.branded_domains.get('*'))
257 if desired_domain == current_domain:
258 return None
259 return desired_domain
260
261
262def FormatURL(recognized_params, url, **kwargs):
263 # type: (Sequence[Tuple(str, str)], str, **Any) -> str
264 """Return a project relative URL to a servlet with old and new params.
265
266 Args:
267 recognized_params: Default query parameters to include.
268 url: Base URL. Could be a relative path for an EZT Servlet or an
269 absolute path for a separate service (ie: besearch).
270 **kwargs: Additional query parameters to add.
271
272 Returns:
273 A URL with the specified query parameters.
274 """
275 # Standard params not overridden in **kwargs come first, followed by kwargs.
276 # The exception is the 'id' param. If present then the 'id' param always comes
277 # first. See bugs.chromium.org/p/monorail/issues/detail?id=374
278 all_params = []
279 if kwargs.get('id'):
280 all_params.append(('id', kwargs['id']))
281 # TODO(jojwang): update all calls to FormatURL to only include non-None
282 # recognized_params
283 if recognized_params:
284 all_params.extend(
285 param for param in recognized_params if param[0] not in kwargs)
286
287 all_params.extend(
288 # Ignore the 'id' param since we already added it above.
289 sorted([kwarg for kwarg in kwargs.items() if kwarg[0] != 'id']))
290 return _FormatQueryString(url, all_params)
291
292
293def _FormatQueryString(url, params):
294 # type: (str, Sequence[Tuple(str, str)]) -> str
295 """URLencode a list of parameters and attach them to the end of a URL.
296
297 Args:
298 url: URL to append the querystring to.
299 params: List of query parameters to append.
300
301 Returns:
302 A URL with the specified query parameters.
303 """
304 param_string = '&'.join(
305 '%s=%s' % (name, urllib.quote(six.text_type(value).encode('utf-8')))
306 for name, value in params if value is not None)
307 if not param_string:
308 qs_start_char = ''
309 elif '?' in url:
310 qs_start_char = '&'
311 else:
312 qs_start_char = '?'
313 return '%s%s%s' % (url, qs_start_char, param_string)
314
315
316def WordWrapSuperLongLines(s, max_cols=100):
317 """Reformat input that was not word-wrapped by the browser.
318
319 Args:
320 s: the string to be word-wrapped, it may have embedded newlines.
321 max_cols: int maximum line length.
322
323 Returns:
324 Wrapped text string.
325
326 Rather than wrap the whole thing, we only wrap super-long lines and keep
327 all the reasonable lines formated as-is.
328 """
329 lines = [textwrap.fill(line, max_cols) for line in s.splitlines()]
330 wrapped_text = '\n'.join(lines)
331
332 # The split/join logic above can lose one final blank line.
333 if s.endswith('\n') or s.endswith('\r'):
334 wrapped_text += '\n'
335
336 return wrapped_text
337
338
339def StaticCacheHeaders():
340 """Returns HTTP headers for static content, based on the current time."""
341 year_from_now = int(time.time()) + framework_constants.SECS_PER_YEAR
342 headers = [
343 ('Cache-Control',
344 'max-age=%d, private' % framework_constants.SECS_PER_YEAR),
345 ('Last-Modified', timestr.TimeForHTMLHeader()),
346 ('Expires', timestr.TimeForHTMLHeader(when=year_from_now)),
347 ]
348 logging.info('static headers are %r', headers)
349 return headers
350
351
352def ComputeListDeltas(old_list, new_list):
353 """Given an old and new list, return the items added and removed.
354
355 Args:
356 old_list: old list of values for comparison.
357 new_list: new list of values for comparison.
358
359 Returns:
360 Two lists: one with all the values added (in new_list but was not
361 in old_list), and one with all the values removed (not in new_list
362 but was in old_lit).
363 """
364 if old_list == new_list:
365 return [], [] # A common case: nothing was added or removed.
366
367 added = set(new_list)
368 added.difference_update(old_list)
369 removed = set(old_list)
370 removed.difference_update(new_list)
371 return list(added), list(removed)
372
373
374def GetRoleName(effective_ids, project):
375 """Determines the name of the role a member has for a given project.
376
377 Args:
378 effective_ids: set of user IDs to get the role name for.
379 project: Project PB containing the different the different member lists.
380
381 Returns:
382 The name of the role.
383 """
384 if not effective_ids.isdisjoint(project.owner_ids):
385 return 'Owner'
386 if not effective_ids.isdisjoint(project.committer_ids):
387 return 'Committer'
388 if not effective_ids.isdisjoint(project.contributor_ids):
389 return 'Contributor'
390 return None
391
392
393def GetHotlistRoleName(effective_ids, hotlist):
394 """Determines the name of the role a member has for a given hotlist."""
395 if not effective_ids.isdisjoint(hotlist.owner_ids):
396 return 'Owner'
397 if not effective_ids.isdisjoint(hotlist.editor_ids):
398 return 'Editor'
399 if not effective_ids.isdisjoint(hotlist.follower_ids):
400 return 'Follower'
401 return None
402
403
404class UserSettings(object):
405 """Abstract class providing static methods for user settings forms."""
406
407 @classmethod
408 def GatherUnifiedSettingsPageData(
409 cls, logged_in_user_id, settings_user_view, settings_user,
410 settings_user_prefs):
411 """Gather EZT variables needed for the unified user settings form.
412
413 Args:
414 logged_in_user_id: The user ID of the acting user.
415 settings_user_view: The UserView of the target user.
416 settings_user: The User PB of the target user.
417 settings_user_prefs: UserPrefs object for the view user.
418
419 Returns:
420 A dictionary giving the names and values of all the variables to
421 be exported to EZT to support the unified user settings form template.
422 """
423
424 settings_user_prefs_view = template_helpers.EZTItem(
425 **{name: None for name in framework_bizobj.USER_PREF_DEFS})
426 if settings_user_prefs:
427 for upv in settings_user_prefs.prefs:
428 if upv.value == 'true':
429 setattr(settings_user_prefs_view, upv.name, True)
430 elif upv.value == 'false':
431 setattr(settings_user_prefs_view, upv.name, None)
432
433 logging.info('settings_user_prefs_view is %r' % settings_user_prefs_view)
434 return {
435 'settings_user': settings_user_view,
436 'settings_user_pb': template_helpers.PBProxy(settings_user),
437 'settings_user_is_banned': ezt.boolean(settings_user.banned),
438 'self': ezt.boolean(logged_in_user_id == settings_user_view.user_id),
439 'profile_url_fragment': (
440 settings_user_view.profile_url[len('/u/'):]),
441 'preview_on_hover': ezt.boolean(settings_user.preview_on_hover),
442 'settings_user_prefs': settings_user_prefs_view,
443 }
444
445 @classmethod
446 def ProcessBanForm(
447 cls, cnxn, user_service, post_data, user_id, user):
448 """Process the posted form data from the ban user form.
449
450 Args:
451 cnxn: connection to the SQL database.
452 user_service: An instance of UserService for saving changes.
453 post_data: The parsed post data from the form submission request.
454 user_id: The user id of the target user.
455 user: The user PB of the target user.
456 """
457 user_service.UpdateUserBan(
458 cnxn, user_id, user, is_banned='banned' in post_data,
459 banned_reason=post_data.get('banned_reason', ''))
460
461 @classmethod
462 def ProcessSettingsForm(
463 cls, we, post_data, user, admin=False):
464 """Process the posted form data from the unified user settings form.
465
466 Args:
467 we: A WorkEnvironment with cnxn and services.
468 post_data: The parsed post data from the form submission request.
469 user: The user PB of the target user.
470 admin: Whether settings reserved for admins are supported.
471 """
472 obscure_email = 'obscure_email' in post_data
473
474 kwargs = {}
475 if admin:
476 kwargs.update(is_site_admin='site_admin' in post_data)
477 kwargs.update(is_banned='banned' in post_data,
478 banned_reason=post_data.get('banned_reason', ''))
479
480 we.UpdateUserSettings(
481 user, notify='notify' in post_data,
482 notify_starred='notify_starred' in post_data,
483 email_compact_subject='email_compact_subject' in post_data,
484 email_view_widget='email_view_widget' in post_data,
485 notify_starred_ping='notify_starred_ping' in post_data,
486 preview_on_hover='preview_on_hover' in post_data,
487 obscure_email=obscure_email,
488 vacation_message=post_data.get('vacation_message', ''),
489 **kwargs)
490
491 user_prefs = []
492 for pref_name in ['restrict_new_issues', 'public_issue_notice']:
493 user_prefs.append(user_pb2.UserPrefValue(
494 name=pref_name,
495 value=('true' if pref_name in post_data else 'false')))
496 we.SetUserPrefs(user.user_id, user_prefs)
497
498
499def GetHostPort(project_name=None):
500 """Get string domain name and port number."""
501
502 app_id = app_identity.get_application_id()
503 if ':' in app_id:
504 domain, app_id = app_id.split(':')
505 else:
506 domain = ''
507
508 if domain.startswith('google'):
509 hostport = '%s.googleplex.com' % app_id
510 else:
511 hostport = '%s.appspot.com' % app_id
512
513 live_site_domain = GetPreferredDomain(hostport)
514 if project_name:
515 project_needed_domain = GetNeededDomain(project_name, live_site_domain)
516 if project_needed_domain:
517 return project_needed_domain
518
519 return live_site_domain
520
521
522def IssueCommentURL(
523 hostport, project, local_id, seq_num=None):
524 """Return a URL pointing directly to the specified comment."""
525 servlet_name = urls.ISSUE_DETAIL
526 detail_url = FormatAbsoluteURLForDomain(
527 hostport, project.project_name, servlet_name, id=local_id)
528 if seq_num:
529 detail_url += '#c%d' % seq_num
530
531 return detail_url
532
533
534def MurmurHash3_x86_32(key, seed=0x0):
535 """Implements the x86/32-bit version of Murmur Hash 3.0.
536
537 MurmurHash3 is written by Austin Appleby, and is placed in the public
538 domain. See https://code.google.com/p/smhasher/ for details.
539
540 This pure python implementation of the x86/32 bit version of MurmurHash3 is
541 written by Fredrik Kihlander and also placed in the public domain.
542 See https://github.com/wc-duck/pymmh3 for details.
543
544 The MurmurHash3 algorithm is chosen for these reasons:
545 * It is fast, even when implemented in pure python.
546 * It is remarkably well distributed, and unlikely to cause collisions.
547 * It is stable and unchanging (any improvements will be in MurmurHash4).
548 * It is well-tested, and easily usable in other contexts (such as bulk
549 data imports).
550
551 Args:
552 key (string): the data that you want hashed
553 seed (int): An offset, treated as essentially part of the key.
554
555 Returns:
556 A 32-bit integer (can be interpreted as either signed or unsigned).
557 """
558 key = bytearray(key.encode('utf-8'))
559
560 def fmix(h):
561 h ^= h >> 16
562 h = (h * 0x85ebca6b) & 0xFFFFFFFF
563 h ^= h >> 13
564 h = (h * 0xc2b2ae35) & 0xFFFFFFFF
565 h ^= h >> 16
566 return h;
567
568 length = len(key)
569 nblocks = int(length // 4)
570
571 h1 = seed;
572
573 c1 = 0xcc9e2d51
574 c2 = 0x1b873593
575
576 # body
577 for block_start in range(0, nblocks * 4, 4):
578 k1 = key[ block_start + 3 ] << 24 | \
579 key[ block_start + 2 ] << 16 | \
580 key[ block_start + 1 ] << 8 | \
581 key[ block_start + 0 ]
582
583 k1 = c1 * k1 & 0xFFFFFFFF
584 k1 = (k1 << 15 | k1 >> 17) & 0xFFFFFFFF
585 k1 = (c2 * k1) & 0xFFFFFFFF;
586
587 h1 ^= k1
588 h1 = ( h1 << 13 | h1 >> 19 ) & 0xFFFFFFFF
589 h1 = ( h1 * 5 + 0xe6546b64 ) & 0xFFFFFFFF
590
591 # tail
592 tail_index = nblocks * 4
593 k1 = 0
594 tail_size = length & 3
595
596 if tail_size >= 3:
597 k1 ^= key[ tail_index + 2 ] << 16
598 if tail_size >= 2:
599 k1 ^= key[ tail_index + 1 ] << 8
600 if tail_size >= 1:
601 k1 ^= key[ tail_index + 0 ]
602
603 if tail_size != 0:
604 k1 = ( k1 * c1 ) & 0xFFFFFFFF
605 k1 = ( k1 << 15 | k1 >> 17 ) & 0xFFFFFFFF
606 k1 = ( k1 * c2 ) & 0xFFFFFFFF
607 h1 ^= k1
608
609 return fmix( h1 ^ length )
610
611
612def MakeRandomKey(length=RANDOM_KEY_LENGTH, chars=RANDOM_KEY_CHARACTERS):
613 """Return a string with lots of random characters."""
614 chars = [random.choice(chars) for _ in range(length)]
615 return ''.join(chars)
616
617
618def IsServiceAccount(email, client_emails=None):
619 """Return a boolean value whether this email is a service account."""
620 if email.endswith('gserviceaccount.com'):
621 return True
622 if client_emails is None:
623 _, client_emails = (
624 client_config_svc.GetClientConfigSvc().GetClientIDEmails())
625 return email in client_emails
626
627
628def GetPreferredDomain(domain):
629 """Get preferred domain to display.
630
631 The preferred domain replaces app_id for default version of monorail-prod
632 and monorail-staging.
633 """
634 return settings.preferred_domains.get(domain, domain)
635
636
637def GetUserAvailability(user, is_group=False):
638 """Return (str, str) that explains why the user might not be available."""
639 if not user.user_id:
640 return None, None
641 if user.banned:
642 return 'Banned', 'banned'
643 if user.vacation_message:
644 return user.vacation_message, 'none'
645 if user.email_bounce_timestamp:
646 return 'Email to this user bounced', 'none'
647 # No availability shown for user groups, or addresses that are
648 # likely to be mailing lists.
649 if is_group or (user.email and '-' in user.email):
650 return None, None
651 if not user.last_visit_timestamp:
652 return 'User never visited', 'never'
653 secs_ago = int(time.time()) - user.last_visit_timestamp
654 last_visit_str = timestr.FormatRelativeDate(
655 user.last_visit_timestamp, days_only=True)
656 if secs_ago > 30 * framework_constants.SECS_PER_DAY:
657 return 'Last visit > 30 days ago', 'none'
658 if secs_ago > 15 * framework_constants.SECS_PER_DAY:
659 return ('Last visit %s' % last_visit_str), 'unsure'
660 return None, None