#!/usr/bin/env python # -*- coding: utf-8 -*- __author__ = "Screenly, Inc" __copyright__ = "Copyright 2012-2023, Screenly, Inc" __license__ = "Dual License: GPLv2 and Commercial License" import json import pydbus import psutil import re import sh import shutil import time import os import traceback import yaml import uuid from base64 import b64encode from celery import Celery 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, listdir, makedirs, mkdir, path, remove, rename, statvfs, stat, walk from retry.api import retry_call from subprocess import check_output from urlparse import urlparse from flask import Flask, escape, make_response, render_template, request, send_from_directory, url_for, jsonify 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 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, generate_perfect_paper_password, is_docker, get_active_connections, remove_connection, get_node_ip, get_node_mac_address, get_video_duration, is_balena_app, is_demo_node, shutdown_via_balena_supervisor, reboot_via_balena_supervisor, string_to_bool, connect_to_redis, url_fails, validate_url, ) from settings import CONFIGURABLE_SETTINGS, DEFAULTS, LISTEN, PORT, settings, ZmqPublisher, ZmqCollector HOME = getenv('HOME') CELERY_RESULT_BACKEND = getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0') CELERY_BROKER_URL = getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0') CELERY_TASK_RESULT_EXPIRES = timedelta(hours=6) app = Flask(__name__) app.debug = string_to_bool(os.getenv('DEBUG', 'False')) CORS(app) api = Api(app, api_version="v1", title="Screenly OSE API") r = connect_to_redis() celery = Celery( app.name, backend=CELERY_RESULT_BACKEND, broker=CELERY_BROKER_URL, result_expires=CELERY_TASK_RESULT_EXPIRES ) ################################ # Celery tasks ################################ @celery.on_after_configure.connect def setup_periodic_tasks(sender, **kwargs): # Calls cleanup() every hour. sender.add_periodic_task(3600, cleanup.s(), name='cleanup') sender.add_periodic_task(3600, cleanup_usb_assets.s(), name='cleanup_usb_assets') sender.add_periodic_task(60*5, get_display_power.s(), name='display_power') @celery.task def get_display_power(): r.set('display_power', diagnostics.get_display_power()) r.expire('display_power', 3600) @celery.task def cleanup(): sh.find(path.join(HOME, 'screenly_assets'), '-name', '*.tmp', '-delete') @celery.task def reboot_screenly(): """ Background task to reboot Screenly-OSE. """ if is_balena_app(): retry_call(reboot_via_balena_supervisor, tries=5, delay=1) else: r.publish('hostcmd', 'reboot') @celery.task def shutdown_screenly(): """ Background task to shutdown Screenly-OSE. """ if is_balena_app(): retry_call(shutdown_via_balena_supervisor, tries=5, delay=1) else: r.publish('hostcmd', 'shutdown') @celery.task def append_usb_assets(mountpoint): """ @TODO. Fix me. This will not work in Docker. """ settings.load() datetime_now = datetime.now() usb_assets_settings = { 'activate': False, 'copy': False, 'start_date': datetime_now, 'end_date': datetime_now + timedelta(days=7), 'duration': settings['default_duration'] } for root, _, filenames in walk(mountpoint): if 'usb_assets_key.yaml' in filenames: with open("%s/%s" % (root, 'usb_assets_key.yaml'), 'r') as yaml_file: usb_file_settings = yaml.load(yaml_file).get('screenly') if usb_file_settings.get('key') == settings['usb_assets_key']: if usb_file_settings.get('activate'): usb_assets_settings.update({ 'activate': usb_file_settings.get('activate') }) if usb_file_settings.get('copy'): usb_assets_settings.update({ 'copy': usb_file_settings.get('copy') }) if usb_file_settings.get('start_date'): ts = time.mktime(datetime.strptime(usb_file_settings.get('start_date'), "%m/%d/%Y").timetuple()) usb_assets_settings.update({ 'start_date': datetime.utcfromtimestamp(ts) }) if usb_file_settings.get('end_date'): ts = time.mktime(datetime.strptime(usb_file_settings.get('end_date'), "%m/%d/%Y").timetuple()) usb_assets_settings.update({ 'end_date': datetime.utcfromtimestamp(ts) }) if usb_file_settings.get('duration'): usb_assets_settings.update({ 'duration': usb_file_settings.get('duration') }) files = ['%s/%s' % (root, y) for root, _, filenames in walk(mountpoint) for y in filenames] with db.conn(settings['database']) as conn: for filepath in files: asset = prepare_usb_asset(filepath, **usb_assets_settings) if asset: assets_helper.create(conn, asset) break @celery.task def remove_usb_assets(mountpoint): """ @TODO. Fix me. This will not work in Docker. """ settings.load() with db.conn(settings['database']) as conn: for asset in assets_helper.read(conn): if asset['uri'].startswith(mountpoint): assets_helper.delete(conn, asset['asset_id']) @celery.task def cleanup_usb_assets(media_dir='/media'): """ @TODO. Fix me. This will not work in Docker. """ settings.load() mountpoints = ['%s/%s' % (media_dir, x) for x in listdir(media_dir) if path.isdir('%s/%s' % (media_dir, x))] with db.conn(settings['database']) as conn: for asset in assets_helper.read(conn): if asset['uri'].startswith(media_dir): location = re.search(r'^(/\w+/\w+[^/])', asset['uri']) if location: if location.group() not in mountpoints: assets_helper.delete(conn, asset['asset_id']) ################################ # 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): """Screenly template response generator. 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, unicode): 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').encode('utf-8')) 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, unicode): 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_usb_asset(filepath, **kwargs): filetype = guess_type(filepath)[0] if not filetype: return filetype = filetype.split('/')[0] if filetype not in ['image', 'video']: return asset_id = uuid.uuid4().hex asset_name = path.basename(filepath) duration = int(get_video_duration(filepath).total_seconds()) if "video" == filetype else int(kwargs['duration']) if kwargs['copy']: shutil.copy(filepath, path.join(settings['assetdir'], asset_id)) filepath = path.join(settings['assetdir'], asset_id) return { 'asset_id': asset_id, 'duration': duration, 'end_date': kwargs['end_date'], 'is_active': 1, 'is_enabled': kwargs['activate'], 'is_processing': 0, 'mimetype': filetype, 'name': asset_name, 'nocache': 0, 'play_order': 0, 'skip_asset_check': 0, 'start_date': kwargs['start_date'], 'uri': filepath, } 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 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(unicode(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.encode('utf-8') 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, 'a') 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 ''' }, ], '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 ResetWifiConfig(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '204': { 'description': 'Deleted' } } }) def get(self): home = getenv('HOME') file_path = path.join(home, '.screenly/initialized') if path.isfile(file_path): remove(file_path) bus = pydbus.SystemBus() pattern_include = re.compile("wlan*") pattern_exclude = re.compile("ScreenlyOSE-*") wireless_connections = get_active_connections(bus) if wireless_connections is not None: device_uuid = None wireless_connections = filter( lambda c: not pattern_exclude.search(str(c['Id'])), filter( lambda c: pattern_include.search(str(c['Devices'])), wireless_connections ) ) if len(wireless_connections) > 0: device_uuid = wireless_connections[0].get('Uuid') if not device_uuid: raise Exception('The device has no active connection.') remove_connection(bus, device_uuid) return '', 204 class GenerateUsbAssetsKey(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Usb assets key generated', 'schema': { 'type': 'string' } } } }) def get(self): settings['usb_assets_key'] = generate_perfect_paper_password(20, False) settings.save() return settings['usb_assets_key'] class UpgradeScreenly(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Upgrade system' } } }) def post(self): for task in celery.control.inspect(timeout=2.0).active().get('worker@screenly'): if task.get('type') == 'server.upgrade_screenly': return jsonify({'id': task.get('id')}) branch = request.form.get('branch') manage_network = request.form.get('manage_network') system_upgrade = request.form.get('system_upgrade') task = upgrade_screenly.apply_async(args=(branch, manage_network, system_upgrade)) return jsonify({'id': task.id}) @app.route('/upgrade_status/') def upgrade_screenly_status(task_id): status_code = 200 task = upgrade_screenly.AsyncResult(task_id) if task.state == 'PENDING': response = { 'state': task.state, 'status': '' } status_code = 202 elif task.state == 'PROGRESS': response = { 'state': task.state, 'status': task.info.get('status', '') } status_code = 202 elif task.state != 'FAILURE': response = { 'state': task.state, 'status': task.info.get('status', '') } else: response = { 'state': task.state, 'status': str(task.info) } return jsonify(response), status_code class RebootScreenly(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Reboot system' } } }) def post(self): reboot_screenly.apply_async() return '', 200 class ShutdownScreenly(Resource): method_decorators = [api_response, authorized] @swagger.doc({ 'responses': { '200': { 'description': 'Shutdown system' } } }) def post(self): shutdown_screenly.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_info': diagnostics.get_monitor_status(), '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), '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(ResetWifiConfig, '/api/v1/reset_wifi') api.add_resource(GenerateUsbAssetsKey, '/api/v1/generate_usb_assets_key') api.add_resource(UpgradeScreenly, '/api/v1/upgrade_screenly') api.add_resource(RebootScreenly, '/api/v1/reboot_screenly') api.add_resource(ShutdownScreenly, '/api/v1/shutdown_screenly') api.add_resource(ViewerCurrentAsset, '/api/v1/viewer_current_asset') try: my_ip = get_node_ip() except Exception: pass else: SWAGGER_URL = '/api/docs' swagger_address = getenv("SWAGGER_HOST", my_ip) if settings['use_ssl'] or is_demo_node: API_URL = 'https://{}/api/swagger.json'.format(swagger_address) elif LISTEN == '127.0.0.1' or swagger_address != my_ip: API_URL = "http://{}/api/swagger.json".format(swagger_address) else: API_URL = "http://{}:{}/api/swagger.json".format(swagger_address, PORT) swaggerui_blueprint = get_swaggerui_blueprint( SWAGGER_URL, API_URL, config={ 'app_name': "Screenly OSE 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() resin_uuid = getenv("RESIN_UUID", None) ws_addresses = [] if settings['use_ssl']: ws_addresses.append('wss://' + my_ip + '/ws/') else: ws_addresses.append('ws://' + my_ip + '/ws/') if resin_uuid: ws_addresses.append('wss://{}.resindevice.io/ws/'.format(resin_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 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 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 DEFAULTS['viewer'].items(): if field == 'usb_assets_key': if not settings[field]: settings[field] = generate_perfect_paper_password(20, False) settings.save() 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 '' }) 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 }) 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_info = diagnostics.get_monitor_status() 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") screenly_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_info=display_info, display_power=display_power, raspberry_pi_model=raspberry_pi_model, screenly_version=screenly_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'] = getenv('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(): my_ip = get_node_ip().split() return template('splash-page.html', my_ip=my_ip) @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('FLASK_ENV', '') == '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()