blob: 47c34ef8179809db103dd1cebb9c464a6738b3b6 [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
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020020from six.moves.urllib.parse import urlparse, quote, urlunparse
Copybara854996b2021-09-07 19:36:02 +000021
22from google.appengine.api import app_identity
23
24import ezt
25import six
26
27import settings
28from framework import framework_bizobj
29from framework import framework_constants
30from framework import template_helpers
31from framework import timestr
32from framework import urls
33from proto import user_pb2
34from services import client_config_svc
35
36# AttachmentUpload holds the information of an incoming uploaded
37# attachment before it gets saved as a gcs file and saved to the DB.
38AttachmentUpload = collections.namedtuple(
39 'AttachmentUpload', ['filename', 'contents', 'mimetype'])
40# type: (str, str, str) -> None
41
42# For random key generation
43RANDOM_KEY_LENGTH = 128
44RANDOM_KEY_CHARACTERS = string.ascii_letters + string.digits
45
46# params recognized by FormatURL, in the order they will appear in the url
47RECOGNIZED_PARAMS = ['can', 'start', 'num', 'q', 'colspec', 'groupby', 'sort',
48 'show', 'format', 'me', 'table_title', 'projects',
49 'hotlist_id']
50
51
52def retry(tries, delay=1, backoff=2):
53 """A retry decorator with exponential backoff.
54
55 Functions are retried when Exceptions occur.
56
57 Args:
58 tries: int Number of times to retry, set to 0 to disable retry.
59 delay: float Initial sleep time in seconds.
60 backoff: float Must be greater than 1, further failures would sleep
61 delay*=backoff seconds.
62 """
63 if backoff <= 1:
64 raise ValueError("backoff must be greater than 1")
65 if tries < 0:
66 raise ValueError("tries must be 0 or greater")
67 if delay <= 0:
68 raise ValueError("delay must be greater than 0")
69
70 def decorator(func):
71 def wrapper(*args, **kwargs):
72 _tries, _delay = tries, delay
73 _tries += 1 # Ensure we call func at least once.
74 while _tries > 0:
75 try:
76 ret = func(*args, **kwargs)
77 return ret
78 except Exception:
79 _tries -= 1
80 if _tries == 0:
81 logging.error('Exceeded maximum number of retries for %s.',
82 func.__name__)
83 raise
84 trace_str = traceback.format_exc()
85 logging.warning('Retrying %s due to Exception: %s',
86 func.__name__, trace_str)
87 time.sleep(_delay)
88 _delay *= backoff # Wait longer the next time we fail.
89 return wrapper
90 return decorator
91
92
93class PromiseCallback(object):
94 """Executes the work of a Promise and then dereferences everything."""
95
96 def __init__(self, promise, callback, *args, **kwargs):
97 self.promise = promise
98 self.callback = callback
99 self.args = args
100 self.kwargs = kwargs
101
102 def __call__(self):
103 try:
104 self.promise._WorkOnPromise(self.callback, *self.args, **self.kwargs)
105 finally:
106 # Make sure we no longer hold onto references to anything.
107 self.promise = self.callback = self.args = self.kwargs = None
108
109
110class Promise(object):
111 """Class for promises to deliver a value in the future.
112
113 A thread is started to run callback(args), that thread
114 should return the value that it generates, or raise an expception.
115 p.WaitAndGetValue() will block until a value is available.
116 If an exception was raised, p.WaitAndGetValue() will re-raise the
117 same exception.
118 """
119
120 def __init__(self, callback, *args, **kwargs):
121 """Initialize the promise and immediately call the supplied function.
122
123 Args:
124 callback: Function that takes the args and returns the promise value.
125 *args: Any arguments to the target function.
126 **kwargs: Any keyword args for the target function.
127 """
128
129 self.has_value = False
130 self.value = None
131 self.event = threading.Event()
132 self.exception = None
133
134 promise_callback = PromiseCallback(self, callback, *args, **kwargs)
135
136 # Execute the callback in another thread.
137 promise_thread = threading.Thread(target=promise_callback)
138 promise_thread.start()
139
140 def _WorkOnPromise(self, callback, *args, **kwargs):
141 """Run callback to compute the promised value. Save any exceptions."""
142 try:
143 self.value = callback(*args, **kwargs)
144 except Exception as e:
145 trace_str = traceback.format_exc()
146 logging.info('Exception while working on promise: %s\n', trace_str)
147 # Add the stack trace at this point to the exception. That way, in the
148 # logs, we can see what happened further up in the call stack
149 # than WaitAndGetValue(), which re-raises exceptions.
150 e.pre_promise_trace = trace_str
151 self.exception = e
152 finally:
153 self.has_value = True
154 self.event.set()
155
156 def WaitAndGetValue(self):
157 """Block until my value is available, then return it or raise exception."""
158 self.event.wait()
159 if self.exception:
160 raise self.exception # pylint: disable=raising-bad-type
161 return self.value
162
163
164def FormatAbsoluteURLForDomain(
165 host, project_name, servlet_name, scheme='https', **kwargs):
166 """A variant of FormatAbsoluteURL for when request objects are not available.
167
168 Args:
169 host: string with hostname and optional port, e.g. 'localhost:8080'.
170 project_name: the destination project name, if any.
171 servlet_name: site or project-local url fragement of dest page.
172 scheme: url scheme, e.g., 'http' or 'https'.
173 **kwargs: additional query string parameters may be specified as named
174 arguments to this function.
175
176 Returns:
177 A full url beginning with 'http[s]://'.
178 """
179 path_and_args = FormatURL(None, servlet_name, **kwargs)
180
181 if host:
182 domain_port = host.split(':')
183 domain_port[0] = GetPreferredDomain(domain_port[0])
184 host = ':'.join(domain_port)
185
186 absolute_domain_url = '%s://%s' % (scheme, host)
187 if project_name:
188 return '%s/p/%s%s' % (absolute_domain_url, project_name, path_and_args)
189 return absolute_domain_url + path_and_args
190
191
192def FormatAbsoluteURL(
193 mr, servlet_name, include_project=True, project_name=None,
194 scheme=None, copy_params=True, **kwargs):
195 """Return an absolute URL to a servlet with old and new params.
196
197 Args:
198 mr: info parsed from the current request.
199 servlet_name: site or project-local url fragement of dest page.
200 include_project: if True, include the project home url as part of the
201 destination URL (as long as it is specified either in mr
202 or as the project_name param.)
203 project_name: the destination project name, to override
204 mr.project_name if include_project is True.
205 scheme: either 'http' or 'https', to override mr.request.scheme.
206 copy_params: if True, copy well-known parameters from the existing request.
207 **kwargs: additional query string parameters may be specified as named
208 arguments to this function.
209
210 Returns:
211 A full url beginning with 'http[s]://'. The destination URL will be in
212 the same domain as the current request.
213 """
214 path_and_args = FormatURL(
215 [(name, mr.GetParam(name)) for name in RECOGNIZED_PARAMS]
216 if copy_params else None,
217 servlet_name, **kwargs)
218 scheme = scheme or mr.request.scheme
219
220 project_base = ''
221 if include_project:
222 project_base = '/p/%s' % (project_name or mr.project_name)
223
224 return '%s://%s%s%s' % (scheme, mr.request.host, project_base, path_and_args)
225
226
227def FormatMovedProjectURL(mr, moved_to):
228 """Return a transformation of the given url into the given project.
229
230 Args:
231 mr: common information parsed from the HTTP request.
232 moved_to: A string from a project's moved_to field that matches
233 project_constants.RE_PROJECT_NAME.
234
235 Returns:
236 The url transposed into the given destination project.
237 """
238 project_name = moved_to
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200239 _, _, path, parameters, query, fragment_identifier = urlparse(
Copybara854996b2021-09-07 19:36:02 +0000240 mr.current_page_url)
241 # Strip off leading "/p/<moved from project>"
242 path = '/' + path.split('/', 3)[3]
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200243 rest_of_url = urlunparse(
244 ('', '', path, parameters, query, fragment_identifier))
Copybara854996b2021-09-07 19:36:02 +0000245 return '/p/%s%s' % (project_name, rest_of_url)
246
247
248def GetNeededDomain(project_name, current_domain):
249 """Return the branded domain for the project iff not on current_domain."""
250 if (not current_domain or
251 '.appspot.com' in current_domain or
252 ':' in current_domain):
253 return None
254 desired_domain = settings.branded_domains.get(
255 project_name, settings.branded_domains.get('*'))
256 if desired_domain == current_domain:
257 return None
258 return desired_domain
259
260
261def FormatURL(recognized_params, url, **kwargs):
262 # type: (Sequence[Tuple(str, str)], str, **Any) -> str
263 """Return a project relative URL to a servlet with old and new params.
264
265 Args:
266 recognized_params: Default query parameters to include.
267 url: Base URL. Could be a relative path for an EZT Servlet or an
268 absolute path for a separate service (ie: besearch).
269 **kwargs: Additional query parameters to add.
270
271 Returns:
272 A URL with the specified query parameters.
273 """
274 # Standard params not overridden in **kwargs come first, followed by kwargs.
275 # The exception is the 'id' param. If present then the 'id' param always comes
276 # first. See bugs.chromium.org/p/monorail/issues/detail?id=374
277 all_params = []
278 if kwargs.get('id'):
279 all_params.append(('id', kwargs['id']))
280 # TODO(jojwang): update all calls to FormatURL to only include non-None
281 # recognized_params
282 if recognized_params:
283 all_params.extend(
284 param for param in recognized_params if param[0] not in kwargs)
285
286 all_params.extend(
287 # Ignore the 'id' param since we already added it above.
288 sorted([kwarg for kwarg in kwargs.items() if kwarg[0] != 'id']))
289 return _FormatQueryString(url, all_params)
290
291
292def _FormatQueryString(url, params):
293 # type: (str, Sequence[Tuple(str, str)]) -> str
294 """URLencode a list of parameters and attach them to the end of a URL.
295
296 Args:
297 url: URL to append the querystring to.
298 params: List of query parameters to append.
299
300 Returns:
301 A URL with the specified query parameters.
302 """
303 param_string = '&'.join(
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200304 '%s=%s' % (name, quote(six.text_type(value).encode('utf-8')))
305 for name, value in params
306 if value is not None)
Copybara854996b2021-09-07 19:36:02 +0000307 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