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