#!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import unicode_literals from future import standard_library from builtins import str from past.builtins import basestring __author__ = "Screenly, Inc" __copyright__ = "Copyright 2012-2023, Screenly, Inc" __license__ = "Dual License: GPLv2 and Commercial License" import ipaddress import json import logging import psutil import traceback import yaml import uuid from base64 import b64encode from datetime import datetime, timedelta from dateutil import parser as date_parser from functools import wraps from hurry.filesize import size from mimetypes import guess_type, guess_extension from os import getenv, makedirs, mkdir, path, remove, rename, statvfs, stat from urllib.parse import urlparse from flask import ( Flask, escape, make_response, render_template, request, send_from_directory, url_for, ) from flask_cors import CORS from flask_restful_swagger_2 import Api, Resource, Schema, swagger from flask_swagger_ui import get_swaggerui_blueprint from gunicorn.app.base import Application from werkzeug.wrappers import Request from celery_tasks import shutdown_anthias, reboot_anthias from lib import assets_helper from lib import backup_helper from lib import db from lib import diagnostics from lib import queries from lib import raspberry_pi_helper from lib.github import is_up_to_date from lib.auth import authorized from lib.utils import ( download_video_from_youtube, json_dump, is_docker, get_balena_supervisor_version, get_node_ip, get_node_mac_address, get_video_duration, is_balena_app, is_demo_node, connect_to_redis, url_fails, validate_url, ) from settings import ( CONFIGURABLE_SETTINGS, DEFAULTS, LISTEN, PORT, settings, ZmqPublisher, ZmqCollector, ) standard_library.install_aliases() HOME = getenv('HOME') app = Flask(__name__) CORS(app) api = Api(app, api_version="v1", title="Anthias API") r = connect_to_redis() ################################ # Utilities ################################ @api.representation('application/json') def output_json(data, code, headers=None): response = make_response(json_dump(data), code) response.headers.extend(headers or {}) return response def api_error(error): return make_response(json_dump({'error': error}), 500) def template(template_name, **context): """ This is a template response wrapper that shares the same function signature as Flask's render_template() method but also injects some global context.""" # Add global contexts context['date_format'] = settings['date_format'] context['default_duration'] = settings['default_duration'] context['default_streaming_duration'] = ( settings['default_streaming_duration']) context['template_settings'] = { 'imports': ['from lib.utils import template_handle_unicode'], 'default_filters': ['template_handle_unicode'], } context['up_to_date'] = is_up_to_date() context['use_24_hour_clock'] = settings['use_24_hour_clock'] return render_template(template_name, context=context) ################################ # Models ################################ class AssetModel(Schema): type = 'object' properties = { 'asset_id': {'type': 'string'}, 'name': {'type': 'string'}, 'uri': {'type': 'string'}, 'start_date': { 'type': 'string', 'format': 'date-time' }, 'end_date': { 'type': 'string', 'format': 'date-time' }, 'duration': {'type': 'string'}, 'mimetype': {'type': 'string'}, 'is_active': { 'type': 'integer', 'format': 'int64', }, 'is_enabled': { 'type': 'integer', 'format': 'int64', }, 'is_processing': { 'type': 'integer', 'format': 'int64', }, 'nocache': { 'type': 'integer', 'format': 'int64', }, 'play_order': { 'type': 'integer', 'format': 'int64', }, 'skip_asset_check': { 'type': 'integer', 'format': 'int64', } } class AssetRequestModel(Schema): type = 'object' properties = { 'name': {'type': 'string'}, 'uri': {'type': 'string'}, 'start_date': { 'type': 'string', 'format': 'date-time' }, 'end_date': { 'type': 'string', 'format': 'date-time' }, 'duration': {'type': 'string'}, 'mimetype': {'type': 'string'}, 'is_enabled': { 'type': 'integer', 'format': 'int64', }, 'nocache': { 'type': 'integer', 'format': 'int64', }, 'play_order': { 'type': 'integer', 'format': 'int64', }, 'skip_asset_check': { 'type': 'integer', 'format': 'int64', } } required = [ 'name', 'uri', 'mimetype', 'is_enabled', 'start_date', 'end_date'] class AssetContentModel(Schema): type = 'object' properties = { 'type': {'type': 'string'}, 'url': {'type': 'string'}, 'filename': {'type': 'string'}, 'mimetype': {'type': 'string'}, 'content': { 'type': 'string', 'format': 'byte' }, } required = ['type', 'filename'] class AssetPropertiesModel(Schema): type = 'object' properties = { 'name': {'type': 'string'}, 'start_date': { 'type': 'string', 'format': 'date-time' }, 'end_date': { 'type': 'string', 'format': 'date-time' }, 'duration': {'type': 'string'}, 'is_active': { 'type': 'integer', 'format': 'int64', }, 'is_enabled': { 'type': 'integer', 'format': 'int64', }, 'nocache': { 'type': 'integer', 'format': 'int64', }, 'play_order': { 'type': 'integer', 'format': 'int64', }, 'skip_asset_check': { 'type': 'integer', 'format': 'int64', } } ################################ # API ################################ def prepare_asset(request, unique_name=False): req = Request(request.environ) data = None # For backward compatibility try: data = json.loads(req.data) except ValueError: data = json.loads(req.form['model']) except TypeError: data = json.loads(req.form['model']) def get(key): val = data.get(key, '') if isinstance(val, str): return val.strip() elif isinstance(val, basestring): return val.strip().decode('utf-8') else: return val if not all([get('name'), get('uri'), get('mimetype')]): raise Exception( "Not enough information provided. " "Please specify 'name', 'uri', and 'mimetype'." ) name = escape(get('name')) if unique_name: with db.conn(settings['database']) as conn: names = assets_helper.get_names_of_assets(conn) if name in names: i = 1 while True: new_name = '%s-%i' % (name, i) if new_name in names: i += 1 else: name = new_name break asset = { 'name': name, 'mimetype': get('mimetype'), 'asset_id': get('asset_id'), 'is_enabled': get('is_enabled'), 'is_processing': get('is_processing'), 'nocache': get('nocache'), } uri = escape(get('uri')) if uri.startswith('/'): if not path.isfile(uri): raise Exception("Invalid file path. Failed to add asset.") else: if not validate_url(uri): raise Exception("Invalid URL. Failed to add asset.") if not asset['asset_id']: asset['asset_id'] = uuid.uuid4().hex if uri.startswith('/'): rename(uri, path.join(settings['assetdir'], asset['asset_id'])) uri = path.join(settings['assetdir'], asset['asset_id']) if 'youtube_asset' in asset['mimetype']: uri, asset['name'], asset['duration'] = download_video_from_youtube( uri, asset['asset_id']) asset['mimetype'] = 'video' asset['is_processing'] = 1 asset['uri'] = uri if "video" in asset['mimetype']: if get('duration') == 'N/A' or int(get('duration')) == 0: asset['duration'] = int(get_video_duration(uri).total_seconds()) else: # Crashes if it's not an int. We want that. asset['duration'] = int(get('duration')) asset['skip_asset_check'] = ( int(get('skip_asset_check')) if int(get('skip_asset_check')) else 0 ) # parse date via python-dateutil and remove timezone info if get('start_date'): asset['start_date'] = date_parser.parse( get('start_date')).replace(tzinfo=None) else: asset['start_date'] = "" if get('end_date'): asset['end_date'] = date_parser.parse( get('end_date')).replace(tzinfo=None) else: asset['end_date'] = "" return asset def prepare_asset_v1_2(request_environ, asset_id=None, unique_name=False): data = json.loads(request_environ.data) def get(key): val = data.get(key, '') if isinstance(val, str): return val.strip() elif isinstance(val, basestring): return val.strip().decode('utf-8') else: return val if not all([get('name'), get('uri'), get('mimetype'), str(get('is_enabled')), get('start_date'), get('end_date')]): raise Exception( "Not enough information provided. Please specify 'name', " "'uri', 'mimetype', 'is_enabled', 'start_date' and 'end_date'." ) ampfix = "&" name = escape(get('name').replace(ampfix, '&')) if unique_name: with db.conn(settings['database']) as conn: names = assets_helper.get_names_of_assets(conn) if name in names: i = 1 while True: new_name = '%s-%i' % (name, i) if new_name in names: i += 1 else: name = new_name break asset = { 'name': name, 'mimetype': get('mimetype'), 'is_enabled': get('is_enabled'), 'nocache': get('nocache') } uri = ( (get('uri')) .replace(ampfix, '&') .replace('<', '<') .replace('>', '>') .replace('\'', ''') .replace('\"', '"') ) if uri.startswith('/'): if not path.isfile(uri): raise Exception("Invalid file path. Failed to add asset.") else: if not validate_url(uri): raise Exception("Invalid URL. Failed to add asset.") if not asset_id: asset['asset_id'] = uuid.uuid4().hex if not asset_id and uri.startswith('/'): new_uri = "{}{}".format( path.join(settings['assetdir'], asset['asset_id']), get('ext')) rename(uri, new_uri) uri = new_uri if 'youtube_asset' in asset['mimetype']: uri, asset['name'], asset['duration'] = download_video_from_youtube( uri, asset['asset_id']) asset['mimetype'] = 'video' asset['is_processing'] = 1 asset['uri'] = uri if "video" in asset['mimetype']: if get('duration') == 'N/A' or int(get('duration')) == 0: asset['duration'] = int(get_video_duration(uri).total_seconds()) elif get('duration'): # Crashes if it's not an int. We want that. asset['duration'] = int(get('duration')) else: asset['duration'] = 10 asset['play_order'] = get('play_order') if get('play_order') else 0 asset['skip_asset_check'] = ( int(get('skip_asset_check')) if int(get('skip_asset_check')) else 0 ) # parse date via python-dateutil and remove timezone info asset['start_date'] = date_parser.parse( get('start_date')).replace(tzinfo=None) asset['end_date'] = date_parser.parse(get('end_date')).replace(tzinfo=None) return asset def prepare_default_asset(**kwargs): if kwargs['mimetype'] not in ['image', 'video', 'webpage']: return asset_id = 'default_{}'.format(uuid.uuid4().hex) duration = ( int(get_video_duration(kwargs['uri']).total_seconds()) if "video" == kwargs['mimetype'] else kwargs['duration'] ) return { 'asset_id': asset_id, 'duration': duration, 'end_date': kwargs['end_date'], 'is_active': 1, 'is_enabled': True, 'is_processing': 0, 'mimetype': kwargs['mimetype'], 'name': kwargs['name'], 'nocache': 0, 'play_order': 0, 'skip_asset_check': 0, 'start_date': kwargs['start_date'], 'uri': kwargs['uri'] } def add_default_assets(): settings.load() datetime_now = datetime.now() default_asset_settings = { 'start_date': datetime_now, 'end_date': datetime_now.replace(year=datetime_now.year + 6), 'duration': settings['default_duration'] } default_assets_yaml = path.join(HOME, '.screenly/default_assets.yml') with open(default_assets_yaml, 'r') as yaml_file: default_assets = yaml.safe_load(yaml_file).get('assets') with db.conn(settings['database']) as conn: for default_asset in default_assets: default_asset_settings.update({ 'name': default_asset.get('name'), 'uri': default_asset.get('uri'), 'mimetype': default_asset.get('mimetype') }) asset = prepare_default_asset(**default_asset_settings) if asset: assets_helper.create(conn, asset) def remove_default_assets(): settings.load() with db.conn(settings['database']) as conn: for asset in assets_helper.read(conn): if asset['asset_id'].startswith('default_'): assets_helper.delete(conn, asset['asset_id']) def update_asset(asset, data): for key, value in list(data.items()): if ( key in ['asset_id', 'is_processing', 'mimetype', 'uri'] or key not in asset ): continue if key in ['start_date', 'end_date']: value = date_parser.parse(value).replace(tzinfo=None) if key in [ 'play_order', 'skip_asset_check', 'is_enabled', 'is_active', 'nocache', ]: value = int(value) if key == 'duration': if "video" not in asset['mimetype']: continue value = int(value) asset.update({key: value}) # api view decorator. handles errors def api_response(view): @wraps(view) def api_view(*args, **kwargs): try: return view(*args, **kwargs) except Exception as e: traceback.print_exc() return api_error(str(e)) return api_view class Assets(Resource): method_decorators = [authorized] @swagger.doc({ 'responses': { '200': { 'description': 'List of assets', 'schema': { 'type': 'array', 'items': AssetModel } } } }) def get(self): with db.conn(settings['database']) as conn: assets = assets_helper.read(conn) return assets @api_response @swagger.doc({ 'parameters': [ { 'name': 'model', 'in': 'formData', 'type': 'string', 'description': ''' Yes, that is just a string of JSON not JSON itself it will be parsed on the other end. Content-Type: application/x-www-form-urlencoded model: "{ "name": "Website", "mimetype": "webpage", "uri": "http://example.com", "is_active": 0, "start_date": "2017-02-02T00:33:00.000Z", "end_date": "2017-03-01T00:33:00.000Z", "duration": "10", "is_enabled": 0, "is_processing": 0, "nocache": 0, "play_order": 0, "skip_asset_check": 0 }" ''' } ], 'responses': { '201': { 'description': 'Asset created', 'schema': AssetModel } } }) def post(self): asset = prepare_asset(request) if url_fails(asset['uri']): raise Exception("Could not retrieve file. Check the asset URL.") with db.conn(settings['database']) as conn: return assets_helper.create(conn, asset), 201 class Asset(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset' } ], 'responses': { '200': { 'description': 'Asset', 'schema': AssetModel } } }) def get(self, asset_id): with db.conn(settings['database']) as conn: return assets_helper.read(conn, asset_id) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset' }, { 'name': 'model', 'in': 'formData', 'type': 'string', 'description': ''' Content-Type: application/x-www-form-urlencoded model: "{ "asset_id": "793406aa1fd34b85aa82614004c0e63a", "name": "Website", "mimetype": "webpage", "uri": "http://example.com", "is_active": 0, "start_date": "2017-02-02T00:33:00.000Z", "end_date": "2017-03-01T00:33:00.000Z", "duration": "10", "is_enabled": 0, "is_processing": 0, "nocache": 0, "play_order": 0, "skip_asset_check": 0 }" ''' } ], 'responses': { '200': { 'description': 'Asset updated', 'schema': AssetModel } } }) def put(self, asset_id): with db.conn(settings['database']) as conn: return assets_helper.update(conn, asset_id, prepare_asset(request)) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset' }, ], 'responses': { '204': { 'description': 'Deleted' } } }) def delete(self, asset_id): with db.conn(settings['database']) as conn: asset = assets_helper.read(conn, asset_id) try: if asset['uri'].startswith(settings['assetdir']): remove(asset['uri']) except OSError: pass assets_helper.delete(conn, asset_id) return '', 204 # return an OK with no content class AssetsV1_1(Resource): method_decorators = [authorized] @swagger.doc({ 'responses': { '200': { 'description': 'List of assets', 'schema': { 'type': 'array', 'items': AssetModel } } } }) def get(self): with db.conn(settings['database']) as conn: assets = assets_helper.read(conn) return assets @api_response @swagger.doc({ 'parameters': [ { 'in': 'body', 'name': 'model', 'description': 'Adds a asset', 'schema': AssetModel, 'required': True } ], 'responses': { '201': { 'description': 'Asset created', 'schema': AssetModel } } }) def post(self): asset = prepare_asset(request, unique_name=True) if url_fails(asset['uri']): raise Exception("Could not retrieve file. Check the asset URL.") with db.conn(settings['database']) as conn: return assets_helper.create(conn, asset), 201 class AssetV1_1(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset' } ], 'responses': { '200': { 'description': 'Asset', 'schema': AssetModel } } }) def get(self, asset_id): with db.conn(settings['database']) as conn: return assets_helper.read(conn, asset_id) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset', 'required': True }, { 'in': 'body', 'name': 'model', 'description': 'Adds an asset', 'schema': AssetModel, 'required': True } ], 'responses': { '200': { 'description': 'Asset updated', 'schema': AssetModel } } }) def put(self, asset_id): with db.conn(settings['database']) as conn: return assets_helper.update(conn, asset_id, prepare_asset(request)) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset', 'required': True }, ], 'responses': { '204': { 'description': 'Deleted' } } }) def delete(self, asset_id): with db.conn(settings['database']) as conn: asset = assets_helper.read(conn, asset_id) try: if asset['uri'].startswith(settings['assetdir']): remove(asset['uri']) except OSError: pass assets_helper.delete(conn, asset_id) return '', 204 # return an OK with no content class AssetsV1_2(Resource): method_decorators = [authorized] @swagger.doc({ 'responses': { '200': { 'description': 'List of assets', 'schema': { 'type': 'array', 'items': AssetModel } } } }) def get(self): with db.conn(settings['database']) as conn: return assets_helper.read(conn) @api_response @swagger.doc({ 'parameters': [ { 'in': 'body', 'name': 'model', 'description': 'Adds an asset', 'schema': AssetRequestModel, 'required': True } ], 'responses': { '201': { 'description': 'Asset created', 'schema': AssetModel } } }) def post(self): request_environ = Request(request.environ) asset = prepare_asset_v1_2(request_environ, unique_name=True) if not asset['skip_asset_check'] and url_fails(asset['uri']): raise Exception("Could not retrieve file. Check the asset URL.") with db.conn(settings['database']) as conn: assets = assets_helper.read(conn) ids_of_active_assets = [ x['asset_id'] for x in assets if x['is_active']] asset = assets_helper.create(conn, asset) if asset['is_active']: ids_of_active_assets.insert( asset['play_order'], asset['asset_id']) assets_helper.save_ordering(conn, ids_of_active_assets) return assets_helper.read(conn, asset['asset_id']), 201 class AssetV1_2(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset' } ], 'responses': { '200': { 'description': 'Asset', 'schema': AssetModel } } }) def get(self, asset_id): with db.conn(settings['database']) as conn: return assets_helper.read(conn, asset_id) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'ID of an asset', 'required': True }, { 'in': 'body', 'name': 'properties', 'description': 'Properties of an asset', 'schema': AssetPropertiesModel, 'required': True } ], 'responses': { '200': { 'description': 'Asset updated', 'schema': AssetModel } } }) def patch(self, asset_id): data = json.loads(request.data) with db.conn(settings['database']) as conn: asset = assets_helper.read(conn, asset_id) if not asset: raise Exception('Asset not found.') update_asset(asset, data) assets = assets_helper.read(conn) ids_of_active_assets = [ x['asset_id'] for x in assets if x['is_active']] asset = assets_helper.update(conn, asset_id, asset) try: ids_of_active_assets.remove(asset['asset_id']) except ValueError: pass if asset['is_active']: ids_of_active_assets.insert( asset['play_order'], asset['asset_id']) assets_helper.save_ordering(conn, ids_of_active_assets) return assets_helper.read(conn, asset_id) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset', 'required': True }, { 'in': 'body', 'name': 'model', 'description': 'Adds an asset', 'schema': AssetRequestModel, 'required': True } ], 'responses': { '200': { 'description': 'Asset updated', 'schema': AssetModel } } }) def put(self, asset_id): asset = prepare_asset_v1_2(request, asset_id) with db.conn(settings['database']) as conn: assets = assets_helper.read(conn) ids_of_active_assets = [ x['asset_id'] for x in assets if x['is_active']] asset = assets_helper.update(conn, asset_id, asset) try: ids_of_active_assets.remove(asset['asset_id']) except ValueError: pass if asset['is_active']: ids_of_active_assets.insert( asset['play_order'], asset['asset_id']) assets_helper.save_ordering(conn, ids_of_active_assets) return assets_helper.read(conn, asset_id) @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset', 'required': True }, ], 'responses': { '204': { 'description': 'Deleted' } } }) def delete(self, asset_id): with db.conn(settings['database']) as conn: asset = assets_helper.read(conn, asset_id) try: if asset['uri'].startswith(settings['assetdir']): remove(asset['uri']) except OSError: pass assets_helper.delete(conn, asset_id) return '', 204 # return an OK with no content class FileAsset(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'file_upload', 'type': 'file', 'in': 'formData', 'description': 'File to be sent' } ], 'responses': { '200': { 'description': 'File path', 'schema': { 'type': 'string' } } } }) def post(self): req = Request(request.environ) file_upload = req.files.get('file_upload') filename = file_upload.filename file_type = guess_type(filename)[0] if not file_type: raise Exception("Invalid file type.") if file_type.split('/')[0] not in ['image', 'video']: raise Exception("Invalid file type.") file_path = path.join( settings['assetdir'], uuid.uuid5(uuid.NAMESPACE_URL, filename).hex) + ".tmp" if 'Content-Range' in request.headers: range_str = request.headers['Content-Range'] start_bytes = int(range_str.split(' ')[1].split('-')[0]) with open(file_path, 'ab') as f: f.seek(start_bytes) f.write(file_upload.read()) else: file_upload.save(file_path) return {'uri': file_path, 'ext': guess_extension(file_type)} class PlaylistOrder(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'ids', 'in': 'formData', 'type': 'string', 'description': ''' Content-Type: application/x-www-form-urlencoded ids: "793406aa1fd34b85aa82614004c0e63a,1c5cfa719d1f4a9abae16c983a18903b,9c41068f3b7e452baf4dc3f9b7906595" comma separated ids ''' # noqa: E501 }, ], 'responses': { '204': { 'description': 'Sorted' } } }) def post(self): with db.conn(settings['database']) as conn: assets_helper.save_ordering( conn, request.form.get('ids', '').split(',')) class Backup(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Backup filename', 'schema': { 'type': 'string' } } } }) def post(self): filename = backup_helper.create_backup(name=settings['player_name']) return filename, 201 class Recover(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'backup_upload', 'type': 'file', 'in': 'formData' } ], 'responses': { '200': { 'description': 'Recovery successful' } } }) def post(self): publisher = ZmqPublisher.get_instance() req = Request(request.environ) file_upload = (req.files['backup_upload']) filename = file_upload.filename if guess_type(filename)[0] != 'application/x-tar': raise Exception("Incorrect file extension.") try: publisher.send_to_viewer('stop') location = path.join("static", filename) file_upload.save(location) backup_helper.recover(location) return "Recovery successful." finally: publisher.send_to_viewer('play') class Reboot(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Reboot system' } } }) def post(self): reboot_anthias.apply_async() return '', 200 class Shutdown(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Shutdown system' } } }) def post(self): shutdown_anthias.apply_async() return '', 200 class Info(Resource): method_decorators = [api_response, authorized] def get(self): viewlog = "Not yet implemented" # Calculate disk space slash = statvfs("/") free_space = size(slash.f_bavail * slash.f_frsize) display_power = r.get('display_power') return { 'viewlog': viewlog, 'loadavg': diagnostics.get_load_avg()['15 min'], 'free_space': free_space, 'display_power': display_power, 'up_to_date': is_up_to_date() } class AssetsControl(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'command', 'type': 'string', 'in': 'path', 'description': ''' Control commands: next - show next asset previous - show previous asset asset&asset_id - show asset with `asset_id` id ''' } ], 'responses': { '200': { 'description': 'Asset switched' } } }) def get(self, command): publisher = ZmqPublisher.get_instance() publisher.send_to_viewer(command) return "Asset switched" class AssetContent(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'parameters': [ { 'name': 'asset_id', 'type': 'string', 'in': 'path', 'description': 'id of an asset' } ], 'responses': { '200': { 'description': ''' The content of the asset. 'type' can either be 'file' or 'url'. In case of a file, the fields 'mimetype', 'filename', and 'content' will be present. In case of a URL, the field 'url' will be present. ''', 'schema': AssetContentModel } } }) def get(self, asset_id): with db.conn(settings['database']) as conn: asset = assets_helper.read(conn, asset_id) if isinstance(asset, list): raise Exception('Invalid asset ID provided') if path.isfile(asset['uri']): filename = asset['name'] with open(asset['uri'], 'rb') as f: content = f.read() mimetype = guess_type(filename)[0] if not mimetype: mimetype = 'application/octet-stream' result = { 'type': 'file', 'filename': filename, 'content': b64encode(content).decode(), 'mimetype': mimetype } else: result = { 'type': 'url', 'url': asset['uri'] } return result class ViewerCurrentAsset(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Currently displayed asset in viewer', 'schema': AssetModel } } }) def get(self): collector = ZmqCollector.get_instance() publisher = ZmqPublisher.get_instance() publisher.send_to_viewer('current_asset_id') collector_result = collector.recv_json(2000) current_asset_id = collector_result.get('current_asset_id') if not current_asset_id: return [] with db.conn(settings['database']) as conn: return assets_helper.read(conn, current_asset_id) api.add_resource(Assets, '/api/v1/assets') api.add_resource(Asset, '/api/v1/assets/') api.add_resource(AssetsV1_1, '/api/v1.1/assets') api.add_resource(AssetV1_1, '/api/v1.1/assets/') api.add_resource(AssetsV1_2, '/api/v1.2/assets') api.add_resource(AssetV1_2, '/api/v1.2/assets/') api.add_resource(AssetContent, '/api/v1/assets//content') api.add_resource(FileAsset, '/api/v1/file_asset') api.add_resource(PlaylistOrder, '/api/v1/assets/order') api.add_resource(Backup, '/api/v1/backup') api.add_resource(Recover, '/api/v1/recover') api.add_resource(AssetsControl, '/api/v1/assets/control/') api.add_resource(Info, '/api/v1/info') api.add_resource(Reboot, '/api/v1/reboot') api.add_resource(Shutdown, '/api/v1/shutdown') api.add_resource(ViewerCurrentAsset, '/api/v1/viewer_current_asset') try: my_ip = get_node_ip() except Exception: pass else: SWAGGER_URL = '/api/docs' API_URL = "/api/swagger.json" swaggerui_blueprint = get_swaggerui_blueprint( SWAGGER_URL, API_URL, config={ 'app_name': "Anthias API" } ) app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL) ################################ # Views ################################ @app.route('/') @authorized def viewIndex(): player_name = settings['player_name'] my_ip = urlparse(request.host_url).hostname is_demo = is_demo_node() balena_uuid = getenv("BALENA_APP_UUID", None) ws_addresses = [] if settings['use_ssl']: ws_addresses.append('wss://' + my_ip + '/ws/') else: ws_addresses.append('ws://' + my_ip + '/ws/') if balena_uuid: ws_addresses.append( 'wss://{}.balena-devices.com/ws/'.format(balena_uuid)) return template( 'index.html', ws_addresses=ws_addresses, player_name=player_name, is_demo=is_demo, is_balena=is_balena_app(), ) @app.route('/settings', methods=["GET", "POST"]) @authorized def settings_page(): context = {'flash': None} if request.method == "POST": try: # Put some request variables in local variables to make them # easier to read. current_pass = request.form.get('current-password', '') auth_backend = request.form.get('auth_backend', '') if ( auth_backend != settings['auth_backend'] and settings['auth_backend'] ): if not current_pass: raise ValueError( "Must supply current password to change " "authentication method" ) if not settings.auth.check_password(current_pass): raise ValueError("Incorrect current password.") prev_auth_backend = settings['auth_backend'] if not current_pass and prev_auth_backend: current_pass_correct = None else: current_pass_correct = ( settings .auth_backends[prev_auth_backend] .check_password(current_pass) ) next_auth_backend = settings.auth_backends[auth_backend] next_auth_backend.update_settings(current_pass_correct) settings['auth_backend'] = auth_backend for field, default in list(CONFIGURABLE_SETTINGS.items()): value = request.form.get(field, default) if not value and field in [ 'default_duration', 'default_streaming_duration', ]: value = str(0) if isinstance(default, bool): value = value == 'on' if field == 'default_assets' and settings[field] != value: if value: add_default_assets() else: remove_default_assets() settings[field] = value settings.save() publisher = ZmqPublisher.get_instance() publisher.send_to_viewer('reload') context['flash'] = { 'class': "success", 'message': "Settings were successfully saved.", } except ValueError as e: context['flash'] = {'class': "danger", 'message': e} except IOError as e: context['flash'] = {'class': "danger", 'message': e} except OSError as e: context['flash'] = {'class': "danger", 'message': e} else: settings.load() for field, default in list(DEFAULTS['viewer'].items()): context[field] = settings[field] auth_backends = [] for backend in settings.auth_backends_list: if backend.template: html, ctx = backend.template context.update(ctx) else: html = None auth_backends.append({ 'name': backend.name, 'text': backend.display_name, 'template': html, 'selected': ( 'selected' if settings['auth_backend'] == backend.name else '' ) }) try: ip_addresses = get_node_ip().split() except Exception as error: logging.warning(f"Error getting IP addresses: {error}") ip_addresses = ['IP_ADDRESS'] context.update({ 'user': settings['user'], 'need_current_password': bool(settings['auth_backend']), 'is_balena': is_balena_app(), 'is_docker': is_docker(), 'auth_backend': settings['auth_backend'], 'auth_backends': auth_backends, 'ip_addresses': ip_addresses, 'host_user': getenv('HOST_USER') }) return template('settings.html', **context) @app.route('/system-info') @authorized def system_info(): viewlog = ["Yet to be implemented"] loadavg = diagnostics.get_load_avg()['15 min'] display_power = r.get('display_power') # Calculate disk space slash = statvfs("/") free_space = size(slash.f_bavail * slash.f_frsize) # Memory virtual_memory = psutil.virtual_memory() memory = { 'total': virtual_memory.total >> 20, 'used': virtual_memory.used >> 20, 'free': virtual_memory.free >> 20, 'shared': virtual_memory.shared >> 20, 'buff': virtual_memory.buffers >> 20, 'available': virtual_memory.available >> 20 } # Get uptime system_uptime = timedelta(seconds=diagnostics.get_uptime()) # Player name for title player_name = settings['player_name'] raspberry_pi_model = raspberry_pi_helper.parse_cpu_info().get( 'model', "Unknown") version = '{}@{}'.format( diagnostics.get_git_branch(), diagnostics.get_git_short_hash() ) return template( 'system-info.html', player_name=player_name, viewlog=viewlog, loadavg=loadavg, free_space=free_space, uptime=system_uptime, memory=memory, display_power=display_power, raspberry_pi_model=raspberry_pi_model, version=version, mac_address=get_node_mac_address(), is_balena=is_balena_app(), ) @app.route('/integrations') @authorized def integrations(): context = { 'player_name': settings['player_name'], 'is_balena': is_balena_app(), } if context['is_balena']: context['balena_device_id'] = getenv('BALENA_DEVICE_UUID') context['balena_app_id'] = getenv('BALENA_APP_ID') context['balena_app_name'] = getenv('BALENA_APP_NAME') context['balena_supervisor_version'] = get_balena_supervisor_version() context['balena_host_os_version'] = getenv('BALENA_HOST_OS_VERSION') context['balena_device_name_at_init'] = getenv( 'BALENA_DEVICE_NAME_AT_INIT') return template('integrations.html', **context) @app.route('/splash-page') def splash_page(): ip_addresses = [] for ip_address in get_node_ip().split(): ip_address_object = ipaddress.ip_address(ip_address) if isinstance(ip_address_object, ipaddress.IPv6Address): ip_addresses.append(f'http://[{ip_address}]') else: ip_addresses.append(f'http://{ip_address}') return template('splash-page.html', ip_addresses=ip_addresses) @app.errorhandler(403) def mistake403(code): return 'The parameter you passed has the wrong format!' @app.errorhandler(404) def mistake404(code): return 'Sorry, this page does not exist!' ################################ # Static ################################ @app.context_processor def override_url_for(): return dict(url_for=dated_url_for) def dated_url_for(endpoint, **values): if endpoint == 'static': filename = values.get('filename', None) if filename: file_path = path.join(app.root_path, endpoint, filename) if path.isfile(file_path): values['q'] = int(stat(file_path).st_mtime) return url_for(endpoint, **values) @app.route('/static_with_mime/') @authorized def static_with_mime(path): mimetype = request.args['mime'] if 'mime' in request.args else 'auto' return send_from_directory( directory='static', filename=path, mimetype=mimetype) @app.before_first_request def main(): # Make sure the asset folder exist. If not, create it if not path.isdir(settings['assetdir']): mkdir(settings['assetdir']) # Create config dir if it doesn't exist if not path.isdir(settings.get_configdir()): makedirs(settings.get_configdir()) with db.conn(settings['database']) as conn: with db.cursor(conn) as cursor: cursor.execute(queries.exists_table) if cursor.fetchone() is None: cursor.execute(assets_helper.create_assets_table) def is_development(): return getenv('ENVIRONMENT', '') == 'development' if __name__ == "__main__" and not is_development(): config = { 'bind': '{}:{}'.format(LISTEN, PORT), 'threads': 2, 'timeout': 20 } class GunicornApplication(Application): def init(self, parser, opts, args): return config def load(self): return app GunicornApplication().run()