def route_recover_with_secret(secret): # check we have the right token user = db.session.query(User).filter(User.password_recovery == secret).first() if not user: flash('No user with that recovery password', 'danger') return redirect(url_for('main.route_index'), 302) # user has since been disabled if user.auth_type == 'disabled': flash('User has been disabled since the recovery email was sent', 'danger') return redirect(url_for('main.route_index'), 302) # user waited too long if datetime.datetime.utcnow() > user.password_recovery_ts + datetime.timedelta(hours=24): flash('More than 24 hours elapsed since the recovery email was sent', 'warning') return redirect(url_for('main.route_index'), 302) # password is stored hashed password = _generate_password() user.password = password user.password_ts = None user.password_recovery = None user.password_recovery_ts = None user.mtime = datetime.datetime.utcnow() db.session.commit() # send email send_email("[LVFS] Your password has been reset", user.email_address, render_template('email-recover-password.txt', user=user, password=password)) flash('Your password has been reset and an email has been sent with the new details', 'info') return redirect(url_for('main.route_index'), 302)
def route_recover(): """ Shows an account recovery panel for a user """ if request.method != 'POST': return render_template('user-recover.html') if not 'username' in request.form: flash('Unable to recover user as no username', 'danger') return redirect(url_for('main.route_dashboard')) # check exists username = request.form['username'] user = db.session.query(User).filter(User.username == username).first() if not user: flash('Unable to recover password as no username %s found' % username, 'warning') return redirect(url_for('main.route_index'), 302) # set the recovery password try: user.generate_password_recovery() db.session.commit() except RuntimeError as e: flash('Unable to recover password for %s: %s' % (username, str(e)), 'warning') return redirect(url_for('main.route_index'), 302) # send email send_email("[LVFS] Your login details", user.email_address, render_template('email-recover.txt', user=user)) flash('An email has been sent with a recovery link', 'info') return redirect(url_for('main.route_index'), 302)
def route_reset_by_admin(user_id): """ Reset the users password """ # check exists user = db.session.query(User).filter(User.user_id == user_id).first() if not user: flash('No user matched!', 'danger') return redirect(url_for('main.route_dashboard'), 422) # security check if not user.vendor.check_acl('@manage-users'): flash('Permission denied: Unable to modify user as non-admin', 'danger') return redirect(url_for('main.route_dashboard')) # password is stored hashed password = _generate_password() user.password = password user.mtime = datetime.datetime.utcnow() user.password_ts = None db.session.commit() # send email send_email("[LVFS] Your password has been reset", user.email_address, render_template('email-modify-password.txt', user=user, password=password)) flash('Password has been reset and an email has been sent to the user', 'info') return redirect(url_for('users.route_admin', user_id=user_id))
def _demote_back_to_testing(fw): # from the server admin user = db.session.query(User).filter(User.username == '*****@*****.**').first() if not user: return # send email to uploading user if fw.user.get_action('notify-demote-failures'): send_email("[LVFS] Firmware has been demoted", fw.user.email_address, render_template('email-firmware-demote.txt', user=fw.user, fw=fw)) fw.mark_dirty() remote = db.session.query(Remote).filter(Remote.name == 'testing').first() remote.is_dirty = True # asynchronously sign straight away, even public remotes for r in set([remote, fw.remote]): r.is_dirty = True _async_regenerate_remote.apply_async(args=(r.remote_id,), queue='metadata', countdown=1) fw.remote_id = remote.remote_id fw.events.append(FirmwareEvent(remote_id=fw.remote_id, user_id=user.user_id)) db.session.commit() _event_log('Demoted firmware {} as reported success {}%'.format(fw.firmware_id, fw.success))
def _user_email_report(): # find all users for user in db.session.query(User)\ .filter(User.auth_type != 'disabled'): if not user.get_action('notify-non-public'): continue fws_embargo = [] fws_testing = [] for fw in user.fws: if fw.target_duration < datetime.timedelta(days=30): continue if fw.remote.name == 'testing': fws_testing.append(fw) continue if fw.remote.name.startswith('embargo'): fws_embargo.append(fw) continue if fws_embargo or fws_testing: send_email( "[LVFS] List of non-public firmware", user.email_address, render_template('email-non-public.txt', user=user, fws_embargo=fws_embargo, fws_testing=fws_testing))
def _delete_embargo_obsoleted_fw(): # all embargoed firmware emails = defaultdict(list) for fw in db.session.query(Firmware)\ .join(Remote)\ .filter(Remote.name.startswith('embargo'))\ .order_by(Firmware.timestamp.asc()): # less than 6 months old if fw.target_duration < datetime.timedelta(days=30 * 6): continue # check that all the components are available with new versions all_newer = True print(fw.target_duration, fw.remote.name, fw.version_display) for md in fw.mds: md_newest = None for md_new in db.session.query(Component)\ .join(Firmware)\ .join(Remote)\ .filter(Remote.is_public)\ .filter(Component.appstream_id == md.appstream_id)\ .order_by(Firmware.timestamp.asc()): if md_new > md or (md_newest and md_new > md_newest): md_newest = md_new break if not md_newest: all_newer = False print('no newer version of {} {}'.format( md.appstream_id, md.version_display)) break print('{} {} [{}] is newer than {} [{}]'.format( md.appstream_id, md_newest.version_display, md_newest.fw.remote.name, md.version_display, md.fw.remote.name)) if not all_newer: continue # delete, but not purge for another 6 months... _firmware_delete(fw) # dedupe emails by user emails[fw.user].append(fw) # send email to the user that uploaded them, unconditionally for user in emails: send_email( "[LVFS] Firmware has been obsoleted", user.email_address, render_template('email-firmware-obsolete.txt', user=user, fws=emails[user])) # all done db.session.commit()
def _user_disable_notify(): # find all users that have not logged in for over one year, and have never # been warned now = datetime.datetime.utcnow() for user in db.session.query(User)\ .filter(User.auth_type != 'disabled')\ .filter(User.atime < now - datetime.timedelta(days=365))\ .filter(User.unused_notify_ts == None): # send email send_email("[LVFS] User account unused: ACTION REQUIRED", user.email_address, render_template('email-unused.txt', user=user)) user.unused_notify_ts = now db.session.commit()
def _demote_back_to_embargo(fw): # send email to uploading user if fw.user.notify_demote_failures: send_email("[LVFS] Firmware has been demoted", fw.user.email_address, render_template('email-firmware-demote.txt', user=fw.user, fw=fw)) fw.mark_dirty() remote = fw.vendor.remote remote.is_dirty = True fw.remote_id = remote.remote_id fw.events.append(FirmwareEvent(fw.remote_id)) db.session.commit() _event_log('Demoted firmware {} as reported success {}%%'.format(fw.firmware_id, fw.success))
def _demote_back_to_testing(fw): # from the server admin user = db.session.query(User).filter(User.username == '*****@*****.**').first() if not user: return # send email to uploading user if fw.user.get_action('notify-demote-failures'): send_email("[LVFS] Firmware has been demoted", fw.user.email_address, render_template('email-firmware-demote.txt', user=fw.user, fw=fw)) fw.mark_dirty() remote = db.session.query(Remote).filter(Remote.name == 'testing').first() remote.is_dirty = True fw.remote_id = remote.remote_id fw.events.append(FirmwareEvent(remote_id=fw.remote_id, user_id=user.user_id)) db.session.commit() _event_log('Demoted firmware {} as reported success {}%'.format(fw.firmware_id, fw.success))
def route_user_create(vendor_id): """ Add a user to the vendor """ # check exists vendor = db.session.query(Vendor).filter(Vendor.vendor_id == vendor_id).first() if not vendor: flash('Failed to modify vendor: No a vendor with that group ID', 'warning') return redirect(url_for('vendors.route_list'), 302) # security check if not vendor.check_acl('@manage-users'): flash('Permission denied: Unable to modify vendor as non-admin', 'danger') return redirect(url_for('vendors.route_show', vendor_id=vendor_id)) if not 'username' in request.form or not request.form['username']: flash('Unable to add user as no username', 'danger') return redirect(url_for('vendors.route_show', vendor_id=vendor_id)) if not 'display_name' in request.form: flash('Unable to add user as no display_name', 'danger') return redirect(url_for('vendors.route_show', vendor_id=vendor_id)) username = request.form['username'].lower() user = db.session.query(User).filter(User.username == username).first() if user: flash('Failed to add user: Username already exists', 'warning') return redirect(url_for('vendors.route_users', vendor_id=vendor_id), 302) # verify email if not _email_check(username): flash('Failed to add user: Invalid email address', 'warning') return redirect(url_for('users.route_list'), 302) # verify the username matches the allowed vendor glob if not g.user.check_acl('@admin'): if not vendor.username_glob: flash('Failed to add user: '******'Admin has not set the account policy for this vendor', 'warning') return redirect(url_for('vendors.route_users', vendor_id=vendor_id), 302) if not _verify_username_vendor_glob(username, vendor.username_glob): flash('Failed to add user: '******'Email address does not match account policy %s' % vendor.username_glob, 'warning') return redirect(url_for('vendors.route_users', vendor_id=vendor_id), 302) # add user if g.user.vendor.oauth_domain_glob: user = User(username=username, display_name=request.form['display_name'], auth_type='oauth', vendor_id=vendor.vendor_id) else: user = User(username=username, display_name=request.form['display_name'], auth_type='local', otp_secret=_otp_hash(), vendor_id=vendor.vendor_id) # this is stored hashed password = _generate_password() user.password = password db.session.add(user) db.session.commit() # send email if user.auth_type == 'local': send_email("[LVFS] An account has been created", user.email_address, render_template('email-confirm.txt', user=user, password=password)) # done! flash('Added user %i' % user.user_id, 'info') return redirect(url_for('vendors.route_users', vendor_id=vendor_id), 302)
def route_modify_by_admin(user_id): """ Change details about the any user """ # check exists user = db.session.query(User).filter(User.user_id == user_id).first() if not user: flash('No user matched!', 'danger') return redirect(url_for('main.route_dashboard'), 422) # security check if not user.vendor.check_acl('@manage-users'): flash('Permission denied: Unable to modify user as non-admin', 'danger') return redirect(url_for('main.route_dashboard')) if not g.user.check_acl('@admin') and 'vendor_id' in request.form: flash('Permission denied: Unable to modify group for user as non-admin', 'danger') return redirect(url_for('main.route_dashboard')) # user is being promoted, so check the manager already has this attribute if not user.check_acl('@vendor-manager') and 'vendor-manager' in request.form: if not g.user.check_acl('@add-action-vendor-manager'): flash('Permission denied: Unable to promote user to manager', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@researcher') and 'researcher' in request.form: if not g.user.check_acl('@add-action-researcher'): flash('Permission denied: Unable to promote user to researcher', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@analyst') and 'analyst' in request.form: if not g.user.check_acl('@add-action-analyst'): flash('Permission denied: Unable to promote user to analyst', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@qa') and 'qa' in request.form: if not g.user.check_acl('@add-action-qa'): flash('Permission denied: Unable to promote user to QA', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@approved-public') and 'approved-public' in request.form: if not g.user.check_acl('@add-action-approved-public'): flash('Permission denied: Unable to promote user to approved public', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@robot') and 'robot' in request.form: if not g.user.check_acl('@add-action-robot'): flash('Permission denied: Unable to mark user as robot', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@admin') and 'admin' in request.form: if not g.user.check_acl('@add-action-admin'): flash('Permission denied: Unable to mark user as admin', 'danger') return redirect(url_for('main.route_dashboard')) if not user.check_acl('@admin') and 'partner' in request.form: if not g.user.check_acl('@add-action-partner'): flash('Permission denied: Unable to mark user as partner', 'danger') return redirect(url_for('main.route_dashboard')) # set each optional thing in turn old_vendor = user.vendor for key in ['display_name', 'username', 'auth_type', 'vendor_id', 'auth_warning']: if key in request.form: value = request.form[key] if value == '': value = None setattr(user, key, value) # get the new human_user_id if specified if 'human_user' in request.form: username = request.form['human_user'] if username: human_user = db.session.query(User).\ filter(User.username == username).first() if not human_user: flash('Failed to modify profile: Human user %s not found' % username, 'warning') return redirect(url_for('main.route_profile'), 302) user.human_user_id = human_user.user_id else: user.human_user_id = None # unchecked checkbuttons are not included in the form data for key in ['is_otp_enabled']: setattr(user, key, bool(key in request.form)) for key in ['qa', 'analyst', 'vendor-manager', 'researcher', 'approved-public', 'robot', 'admin', 'partner']: if key in request.form: if not user.get_action(key): user.actions.append(UserAction(value=key)) else: action = user.get_action(key) if action: user.actions.remove(action) # password is optional, and hashed if 'password' in request.form and request.form['password']: user.password = request.form['password'] # was disabled? if user.auth_type == 'disabled': if not user.dtime: user.dtime = datetime.datetime.utcnow() else: user.dtime = None user.mtime = datetime.datetime.utcnow() db.session.commit() # reparent any uploaded firmware is_dirty = False reparent = bool('reparent' in request.form) if old_vendor.vendor_id != user.vendor_id and reparent: for fw in db.session.query(Firmware).\ filter(Firmware.user_id == user.user_id): fw.vendor_id = user.vendor_id if fw.remote.name.startswith('embargo'): is_dirty = True fw.remote_id = user.vendor.remote.remote_id for ev in db.session.query(FirmwareEvent).\ filter(FirmwareEvent.user_id == user.user_id): ev.remote_id = user.vendor.remote.remote_id # fix event log if old_vendor.vendor_id != user.vendor_id: for ev in db.session.query(Event).\ filter(Event.user_id == user.user_id): ev.vendor_id = user.vendor_id # mark both remotes as dirty if is_dirty: user.vendor.remote.is_dirty = True old_vendor.remote.is_dirty = True db.session.commit() # send email if 'send_email' in request.form: if old_vendor.vendor_id != user.vendor_id: send_email("[LVFS] Your account has been moved", user.email_address, render_template('email-moved.txt', user=user, old_vendor=old_vendor, reparent=reparent)) else: if user.auth_type == 'disabled': send_email("[LVFS] Your account has been disabled", user.email_address, render_template('email-disabled.txt', user=user)) else: send_email("[LVFS] Your account has been updated", user.email_address, render_template('email-modify.txt', user=user)) flash('Updated profile and sent a notification email to the user', 'info') else: flash('Updated profile', 'info') return redirect(url_for('users.route_admin', user_id=user_id))
def _upload_firmware(): # verify the user can upload if not _user_can_upload(g.user): flash('User has not signed legal agreement', 'danger') return redirect(url_for('main.route_dashboard')) # used a custom vendor_id if 'vendor_id' in request.form: try: vendor_id = int(request.form['vendor_id']) except ValueError as e: flash('Failed to upload file: Specified vendor ID %s invalid' % request.form['vendor_id'], 'warning') return redirect(url_for('upload.route_firmware')) vendor = db.session.query(Vendor).filter(Vendor.vendor_id == vendor_id).first() if not vendor: flash('Failed to upload file: Specified vendor ID not found', 'warning') return redirect(url_for('upload.route_firmware')) else: vendor = g.user.vendor # security check if not vendor.check_acl('@upload'): flash('Permission denied: Failed to upload file for vendor: ' 'User with vendor %s cannot upload to vendor %s' % (g.user.vendor.group_id, vendor.group_id), 'warning') return redirect(url_for('upload.route_firmware')) # not correct parameters if not 'target' in request.form: return _error_internal('No target') if not 'file' in request.files: return _error_internal('No file') if request.form['target'] not in ['private', 'embargo', 'testing']: return _error_internal('Target not valid') # find remote, creating if required remote_name = request.form['target'] if remote_name == 'embargo': remote = vendor.remote else: remote = db.session.query(Remote).filter(Remote.name == remote_name).first() if not remote: return _error_internal('No remote for target %s' % remote_name) # if the vendor has uploaded a lot of firmware don't start changing the rules is_strict = len(vendor.fws) < 500 # load in the archive fileitem = request.files['file'] if not fileitem: return _error_internal('No file object') try: ufile = UploadedFile(is_strict=is_strict) for cat in db.session.query(Category): ufile.category_map[cat.value] = cat.category_id for pro in db.session.query(Protocol): ufile.protocol_map[pro.value] = pro.protocol_id for verfmt in db.session.query(Verfmt): ufile.version_formats[verfmt.value] = verfmt ufile.parse(os.path.basename(fileitem.filename), fileitem.read()) except (FileTooLarge, FileTooSmall, FileNotSupported, MetadataInvalid) as e: flash('Failed to upload file: ' + str(e), 'danger') return redirect(request.url) # check the file does not already exist fw = db.session.query(Firmware)\ .filter(or_(Firmware.checksum_upload_sha1 == ufile.fw.checksum_upload_sha1, Firmware.checksum_upload_sha256 == ufile.fw.checksum_upload_sha256)).first() if fw: if fw.check_acl('@view'): flash('Failed to upload file: A file with hash %s already exists' % fw.checksum_upload_sha1, 'warning') return redirect('/lvfs/firmware/%s' % fw.firmware_id) flash('Failed to upload file: Another user has already uploaded this firmware', 'warning') return redirect(url_for('upload.route_firmware')) # check the guid and version does not already exist fws = db.session.query(Firmware).all() fws_already_exist = [] for md in ufile.fw.mds: provides_value = md.guids[0].value fw = _filter_fw_by_id_guid_version(fws, md.appstream_id, provides_value, md.version) if fw: fws_already_exist.append(fw) # all the components existed, so build an error out of all the versions if len(fws_already_exist) == len(ufile.fw.mds): if g.user.check_acl('@robot') and 'auto-delete' in request.form: for fw in fws_already_exist: if fw.remote.is_public: flash('Firmware {} cannot be autodeleted as is in remote {}'.format( fw.firmware_id, fw.remote.name), 'danger') return redirect(url_for('upload.route_firmware')) if fw.user.user_id != g.user.user_id: flash('Firmware was not uploaded by this user', 'danger') return redirect(url_for('upload.route_firmware')) for fw in fws_already_exist: flash('Firmware %i was auto-deleted due to robot upload' % fw.firmware_id) _firmware_delete(fw) else: versions_for_display = [] for fw in fws_already_exist: for md in fw.mds: if not md.version_display in versions_for_display: versions_for_display.append(md.version_display) flash('Failed to upload file: A firmware file for this device with ' 'version %s already exists' % ','.join(versions_for_display), 'danger') return redirect('/lvfs/firmware/%s' % fw.firmware_id) # check if the file dropped a GUID previously supported for umd in ufile.fw.mds: new_guids = [guid.value for guid in umd.guids] for md in db.session.query(Component).\ filter(Component.appstream_id == umd.appstream_id): if md.fw.is_deleted: continue for old_guid in [guid.value for guid in md.guids]: if old_guid in new_guids: continue fw_str = str(md.fw.firmware_id) if g.user.check_acl('@qa') or g.user.check_acl('@robot'): flash('Firmware drops GUID {} previously supported ' 'in firmware {}'.format(old_guid, fw_str), 'warning') else: flash('Firmware would drop GUID {} previously supported ' 'in firmware {}'.format(old_guid, fw_str), 'danger') return redirect(request.url) # allow plugins to copy any extra files from the source archive for cffile in ufile.cabarchive_upload.values(): ploader.archive_copy(ufile.cabarchive_repacked, cffile) # allow plugins to add files ploader.archive_finalize(ufile.cabarchive_repacked, _get_plugin_metadata_for_uploaded_file(ufile)) # dump to a file download_dir = app.config['DOWNLOAD_DIR'] if not os.path.exists(download_dir): os.mkdir(download_dir) fn = os.path.join(download_dir, ufile.fw.filename) cab_data = ufile.cabarchive_repacked.save(compress=True) with open(fn, 'wb') as f: f.write(cab_data) # create parent firmware object settings = _get_settings() target = request.form['target'] fw = ufile.fw fw.vendor = vendor fw.user = g.user fw.addr = _get_client_address() fw.remote = remote fw.checksum_signed_sha1 = hashlib.sha1(cab_data).hexdigest() fw.checksum_signed_sha256 = hashlib.sha256(cab_data).hexdigest() fw.is_dirty = True fw.failure_minimum = settings['default_failure_minimum'] fw.failure_percentage = settings['default_failure_percentage'] # fix name for md in fw.mds: name_fixed = _fix_component_name(md.name, md.developer_name_display) if name_fixed != md.name: flash('Fixed component name from "%s" to "%s"' % (md.name, name_fixed), 'warning') md.name = name_fixed # verify each component has a version format for md in fw.mds: if not md.verfmt_with_fallback: flash('Component {} does not have required LVFS::VersionFormat'.\ format(md.appstream_id), 'warning') # add to database fw.events.append(FirmwareEvent(remote_id=remote.remote_id, user_id=g.user.user_id)) db.session.add(fw) db.session.commit() # ensure the test has been added for the firmware type ploader.ensure_test_for_fw(fw) # send out emails to anyone interested for u in fw.get_possible_users_to_email: if u == g.user: continue if u.get_action('notify-upload-vendor') and u.vendor == fw.vendor: send_email("[LVFS] Firmware has been uploaded", u.email_address, render_template('email-firmware-uploaded.txt', user=u, user_upload=g.user, fw=fw)) elif u.get_action('notify-upload-affiliate'): send_email("[LVFS] Firmware has been uploaded by affiliate", u.email_address, render_template('email-firmware-uploaded.txt', user=u, user_upload=g.user, fw=fw)) flash('Uploaded file %s to %s' % (ufile.fw.filename, target), 'info') # invalidate if target == 'embargo': remote.is_dirty = True g.user.vendor.remote.is_dirty = True db.session.commit() return redirect(url_for('firmware.route_show', firmware_id=fw.firmware_id))
#!/usr/bin/python3 # -*- coding: utf-8 -*- # # Copyright (C) 2018 Richard Hughes <*****@*****.**> # # SPDX-License-Identifier: GPL-2.0+ # # pylint: disable=singleton-comparison,wrong-import-position import os import sys # allows us to run this from the project root sys.path.append(os.path.realpath('.')) import lvfs as application from lvfs.emails import send_email # make compatible with Flask app = application.app if __name__ == '__main__': with app.test_request_context(): send_email("[LVFS] Test email", '*****@*****.**', 'Still working')
def route_promote(firmware_id, target): """ Promote or demote a firmware file from one target to another, for example from testing to stable, or stable to testing. """ # check valid if target not in ['stable', 'testing', 'private', 'embargo']: return _error_internal("Target %s invalid" % target) # check firmware exists in database fw = db.session.query(Firmware).filter( Firmware.firmware_id == firmware_id).first() if not fw: flash('No firmware {} exists'.format(firmware_id), 'danger') return redirect(url_for('firmware.route_firmware')) # security check if not fw.check_acl('@promote-' + target): flash('Permission denied: No QA access to {}'.format(firmware_id), 'danger') return redirect(url_for('firmware.route_show', firmware_id=firmware_id)) # vendor has to fix the problems first if target in ['stable', 'testing'] and fw.problems: probs = [] for problem in fw.problems: if problem.kind not in probs: probs.append(problem.kind) flash( 'Firmware has problems that must be fixed first: %s' % ','.join(probs), 'warning') return redirect( url_for('firmware.route_problems', firmware_id=firmware_id)) # set new remote if target == 'embargo': remote = fw.vendor.remote else: remote = db.session.query(Remote).filter(Remote.name == target).first() if not remote: return _error_internal('No remote for target %s' % target) # same as before if fw.remote.remote_id == remote.remote_id: flash('Cannot move firmware: Firmware already in that target', 'info') return redirect( url_for('firmware.route_target', firmware_id=firmware_id)) # invalidate both the remote it "came from", the one it's "going to" and # also the remote of the vendor that uploaded it remote.is_dirty = True fw.remote.is_dirty = True fw.vendor_odm.remote.is_dirty = True # invalidate the firmware as we're waiting for the metadata generation fw.mark_dirty() # some tests only run when the firmware is in stable ploader.ensure_test_for_fw(fw) # also dirty any ODM remote if uploading on behalf of an OEM if target == 'embargo' and fw.vendor != fw.user.vendor: fw.user.vendor.remote.is_dirty = True # all okay fw.remote_id = remote.remote_id fw.events.append( FirmwareEvent(remote_id=fw.remote_id, user_id=g.user.user_id)) db.session.commit() # send email for u in fw.get_possible_users_to_email: if u == g.user: continue if u.get_action('notify-promote'): send_email( "[LVFS] Firmware has been promoted", u.email_address, render_template('email-firmware-promoted.txt', user=g.user, fw=fw)) flash('Moved firmware', 'info') return redirect(url_for('firmware.route_target', firmware_id=firmware_id))