addons-server/services/update.py

258 строки
8.7 KiB
Python

import json
import logging.config
import os
from django.utils.encoding import force_bytes
from email.utils import formatdate
import MySQLdb as mysql
from urllib.parse import parse_qsl
import sqlalchemy.pool as pool
from time import time
from services.settings import settings
# This has to be imported after the settings so statsd knows where to log to.
from django_statsd.clients import statsd
from olympia.constants import applications, base
from olympia.versions.compare import version_int
import olympia.core.logger
def get_connection():
db = settings.SERVICES_DATABASE
return mysql.connect(
host=db['HOST'],
user=db['USER'],
passwd=db['PASSWORD'],
db=db['NAME'],
charset=db['OPTIONS']['charset'],
)
pool = pool.QueuePool(get_connection, max_overflow=10, pool_size=5, recycle=300)
class Update:
def __init__(self, data, compat_mode='strict'):
self.connection, self.cursor = None, None
self.data = data.copy()
self.data['row'] = {}
self.version_int = 0
self.compat_mode = compat_mode
self.app = applications.APP_GUIDS.get(data.get('appID'))
def is_valid(self):
# If you accessing this from unit tests, then before calling
# is valid, you can assign your own cursor.
if not self.cursor:
self.connection = pool.connect()
self.cursor = self.connection.cursor()
data = self.data
# Version can be blank.
data['version'] = data.get('version', '')
for field in ['reqVersion', 'id', 'appID', 'appVersion']:
if field not in data:
return False
if not self.app:
return False
data['app_id'] = self.app.id
sql = """SELECT `id`, `status`, `guid` FROM `addons`
WHERE `guid` = %(guid)s AND
`inactive` = 0 AND
`status` NOT IN (%(STATUS_DELETED)s, %(STATUS_DISABLED)s)
LIMIT 1;"""
self.cursor.execute(
sql,
{
'guid': self.data['id'],
'STATUS_DELETED': base.STATUS_DELETED,
'STATUS_DISABLED': base.STATUS_DISABLED,
},
)
result = self.cursor.fetchone()
if result is None:
return False
data['id'], data['addon_status'], data['guid'] = result
data['version_int'] = version_int(data['appVersion'])
return True
def get_update(self):
data = self.data
data['STATUS_APPROVED'] = base.STATUS_APPROVED
data['RELEASE_CHANNEL_LISTED'] = base.RELEASE_CHANNEL_LISTED
sql = [
"""
SELECT
`addons`.`guid` AS `guid`,
`addons`.`slug` AS `slug`,
`appmin`.`version` AS `min`,
`appmax`.`version` AS `max`,
`files`.`hash`,
`files`.`filename`,
`files`.`id` AS `file_id`,
`versions`.`id` AS `version_id`,
`files`.`strict_compatibility` AS `strict_compat`,
`versions`.`releasenotes`,
`versions`.`version` AS `version`
FROM `versions`
INNER JOIN `addons`
ON `addons`.`id` = `versions`.`addon_id`
AND `addons`.`id` = %(id)s
INNER JOIN `applications_versions`
ON `applications_versions`.`version_id` = `versions`.`id`
INNER JOIN `appversions` `appmin`
ON `appmin`.`id` = `applications_versions`.`min`
AND `appmin`.`application_id` = %(app_id)s
INNER JOIN `appversions` `appmax`
ON `appmax`.`id` = `applications_versions`.`max`
AND `appmax`.`application_id` = %(app_id)s
INNER JOIN `files`
ON `files`.`version_id` = `versions`.`id`
-- Find a reference to the user's current version, if it exists.
-- These should never be inner joins. We need results even if we
-- can't find the current version.
LEFT JOIN `versions` `curver`
ON `curver`.`addon_id` = `addons`.`id`
AND `curver`.`version` = %(version)s
LEFT JOIN `files` `curfile`
ON `curfile`.`version_id` = `curver`.`id`
WHERE
`versions`.`deleted` = 0
AND `versions`.`channel` = %(RELEASE_CHANNEL_LISTED)s
AND `files`.`status` = %(STATUS_APPROVED)s
AND `appmin`.`version_int` <= %(version_int)s
"""
]
if self.compat_mode == 'ignore':
pass # no further SQL modification required.
elif self.compat_mode == 'normal':
# When file has strict_compatibility enabled, default to compatible
# is disabled.
sql.append(
"""AND
CASE WHEN `files`.`strict_compatibility` = 1
THEN `appmax`.`version_int` >= %(version_int)s ELSE 1 END
"""
)
else: # Not defined or 'strict'.
sql.append('AND `appmax`.`version_int` >= %(version_int)s ')
sql.append('ORDER BY `versions`.`id` DESC LIMIT 1;')
self.cursor.execute(''.join(sql), data)
result = self.cursor.fetchone()
if result:
data['row'] = dict(
zip(
[
'guid',
'slug',
'min',
'max',
'hash',
'filename',
'file_id',
'version_id',
'strict_compat',
'releasenotes',
'version',
],
list(result),
)
)
return True
return False
def get_output(self):
if self.is_valid():
if self.get_update():
contents = self.get_success_output()
else:
contents = self.get_no_updates_output()
else:
contents = self.get_error_output()
self.cursor.close()
if self.connection:
self.connection.close()
return json.dumps(contents)
def get_error_output(self):
return {}
def get_no_updates_output(self):
return {'addons': {self.data['guid']: {'updates': []}}}
def get_success_output(self):
data = self.data['row']
slug = data['slug']
version = data['version']
file_id = data['file_id']
filename = os.path.basename(data['filename'])
update = {
'version': data['version'],
# This is essentially re-implementing File.get_absolute_url()
# without needing django.
'update_link': (
f'{settings.SITE_URL}/{self.app.short}/'
f'downloads/file/{file_id}/{filename}'
),
'applications': {'gecko': {'strict_min_version': data['min']}},
}
if data['strict_compat']:
update['applications']['gecko']['strict_max_version'] = data['max']
if data['hash']:
update['update_hash'] = data['hash']
if data['releasenotes']:
update['update_info_url'] = (
f'{settings.SITE_URL}/%APP_LOCALE%/'
f'{self.app.short}/addon/{slug}/versions/{version}/updateinfo/'
)
return {'addons': {self.data['guid']: {'updates': [update]}}}
def format_date(self, secs):
return '%s GMT' % formatdate(time() + secs)[:25]
def get_headers(self, length):
content_type = 'application/json'
return [
('Content-Type', content_type),
('Cache-Control', 'public, max-age=3600'),
('Last-Modified', self.format_date(0)),
('Expires', self.format_date(3600)),
('Content-Length', str(length)),
]
def application(environ, start_response):
# Logging has to be configured before it can be used. In the django app
# this is done through settings.LOGGING but the update service is its own
# separate wsgi app.
logging.config.dictConfig(settings.LOGGING)
# Now we can get our logger instance.
log = olympia.core.logger.getLogger('z.services')
status = '200 OK'
with statsd.timer('services.update'):
data = dict(parse_qsl(environ['QUERY_STRING']))
compat_mode = data.pop('compatMode', 'strict')
try:
update = Update(data, compat_mode)
output = force_bytes(update.get_output())
start_response(status, update.get_headers(len(output)))
except Exception as e:
log.exception(e)
raise
return [output]