1# -*- coding: utf-8 -*-
2
3#  This file is part of Tautulli.
4#
5#  Tautulli is free software: you can redistribute it and/or modify
6#  it under the terms of the GNU General Public License as published by
7#  the Free Software Foundation, either version 3 of the License, or
8#  (at your option) any later version.
9#
10#  Tautulli is distributed in the hope that it will be useful,
11#  but WITHOUT ANY WARRANTY; without even the implied warranty of
12#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13#  GNU General Public License for more details.
14#
15#  You should have received a copy of the GNU General Public License
16#  along with Tautulli.  If not, see <http://www.gnu.org/licenses/>.
17
18from __future__ import unicode_literals
19from future.builtins import str
20
21from logging import handlers
22
23import cherrypy
24import logging
25import os
26import re
27import sys
28import threading
29import traceback
30
31import plexpy
32if plexpy.PYTHON2:
33    import helpers
34    from config import _BLACKLIST_KEYS, _WHITELIST_KEYS
35else:
36    from plexpy import helpers
37    from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS
38
39
40# These settings are for file logging only
41FILENAME = "tautulli.log"
42FILENAME_API = "tautulli_api.log"
43FILENAME_PLEX_WEBSOCKET = "plex_websocket.log"
44MAX_SIZE = 5000000  # 5 MB
45MAX_FILES = 5
46
47_BLACKLIST_WORDS = set()
48
49# Tautulli logger
50logger = logging.getLogger("tautulli")
51# Tautulli API logger
52logger_api = logging.getLogger("tautulli_api")
53# Tautulli websocket logger
54logger_plex_websocket = logging.getLogger("plex_websocket")
55
56# Global queue for multiprocessing logging
57queue = None
58
59
60def blacklist_config(config):
61    blacklist = set()
62    blacklist_keys = ['HOOK', 'APIKEY', 'KEY', 'PASSWORD', 'TOKEN']
63
64    for key, value in config.items():
65        if isinstance(value, str) and len(value.strip()) > 5 and \
66            key.upper() not in _WHITELIST_KEYS and (key.upper() in blacklist_keys or
67                                                    any(bk in key.upper() for bk in _BLACKLIST_KEYS)):
68            blacklist.add(value.strip())
69
70    _BLACKLIST_WORDS.update(blacklist)
71
72
73class NoThreadFilter(logging.Filter):
74    """
75    Log filter for the current thread
76    """
77    def __init__(self, threadName):
78        super(NoThreadFilter, self).__init__()
79
80        self.threadName = threadName
81
82    def filter(self, record):
83        return not record.threadName == self.threadName
84
85
86# Taken from Hellowlol/HTPC-Manager
87class BlacklistFilter(logging.Filter):
88    """
89    Log filter for blacklisted tokens and passwords
90    """
91    def __init__(self):
92        super(BlacklistFilter, self).__init__()
93
94    def filter(self, record):
95        if not plexpy.CONFIG.LOG_BLACKLIST:
96            return True
97
98        for item in _BLACKLIST_WORDS:
99            try:
100                if item in record.msg:
101                    record.msg = record.msg.replace(item, 16 * '*')
102
103                args = []
104                for arg in record.args:
105                    try:
106                        arg_str = str(arg)
107                        if item in arg_str:
108                            arg_str = arg_str.replace(item, 16 * '*')
109                            arg = arg_str
110                    except:
111                        pass
112                    args.append(arg)
113                record.args = tuple(args)
114            except:
115                pass
116
117        return True
118
119
120class RegexFilter(logging.Filter):
121    """
122    Base class for regex log filter
123    """
124    def __init__(self):
125        super(RegexFilter, self).__init__()
126
127        self.regex = re.compile(r'')
128
129    def filter(self, record):
130        if not plexpy.CONFIG.LOG_BLACKLIST:
131            return True
132
133        try:
134            matches = self.regex.findall(record.msg)
135            for match in matches:
136                record.msg = self.replace(record.msg, match)
137
138            args = []
139            for arg in record.args:
140                try:
141                    arg_str = str(arg)
142                    matches = self.regex.findall(arg_str)
143                    if matches:
144                        for match in matches:
145                            arg_str = self.replace(arg_str, match)
146                        arg = arg_str
147                except:
148                    pass
149                args.append(arg)
150            record.args = tuple(args)
151        except:
152            pass
153
154        return True
155
156    def replace(self, text, match):
157        return text
158
159
160class PublicIPFilter(RegexFilter):
161    """
162    Log filter for public IP addresses
163    """
164    def __init__(self):
165        super(PublicIPFilter, self).__init__()
166
167        # Currently only checking for ipv4 addresses
168        self.regex = re.compile(r'[0-9]+(?:[.-][0-9]+){3}(?!\d*-[a-z0-9]{6})')
169
170    def replace(self, text, ip):
171        if helpers.is_public_ip(ip.replace('-', '.')):
172            partition = '-' if '-' in ip else '.'
173            return text.replace(ip, partition.join(['***'] * 4))
174        return text
175
176
177class EmailFilter(RegexFilter):
178    """
179    Log filter for email addresses
180    """
181    def __init__(self):
182        super(EmailFilter, self).__init__()
183
184        self.regex = re.compile(r'([a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@'
185                                r'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)',
186                                re.IGNORECASE)
187
188    def replace(self, text, email):
189        email_parts = email.partition('@')
190        return text.replace(email, 16 * '*' + email_parts[1] + 8 * '*')
191
192
193class PlexTokenFilter(RegexFilter):
194    """
195    Log filter for X-Plex-Token
196    """
197    def __init__(self):
198        super(PlexTokenFilter, self).__init__()
199
200        self.regex = re.compile(r'X-Plex-Token(?:=|%3D)([a-zA-Z0-9]+)')
201
202    def replace(self, text, token):
203        return text.replace(token, 16 * '*')
204
205
206def initLogger(console=False, log_dir=False, verbose=False):
207    """
208    Setup logging for Tautulli. It uses the logger instance with the name
209    'tautulli'. Three log handlers are added:
210
211    * RotatingFileHandler: for the file tautulli.log
212    * LogListHandler: for Web UI
213    * StreamHandler: for console (if console)
214
215    Console logging is only enabled if console is set to True. This method can
216    be invoked multiple times, during different stages of Tautulli.
217    """
218
219    # Close and remove old handlers. This is required to reinit the loggers
220    # at runtime
221    log_handlers = logger.handlers[:] + \
222                   logger_api.handlers[:] + \
223                   logger_plex_websocket.handlers[:] + \
224                   cherrypy.log.error_log.handlers[:]
225    for handler in log_handlers:
226        # Just make sure it is cleaned up.
227        if isinstance(handler, handlers.RotatingFileHandler):
228            handler.close()
229        elif isinstance(handler, logging.StreamHandler):
230            handler.flush()
231
232        logger.removeHandler(handler)
233        logger_api.removeHandler(handler)
234        logger_plex_websocket.removeHandler(handler)
235        cherrypy.log.error_log.removeHandler(handler)
236
237    # Configure the logger to accept all messages
238    logger.propagate = False
239    logger.setLevel(logging.DEBUG if verbose else logging.INFO)
240    logger_api.propagate = False
241    logger_api.setLevel(logging.DEBUG if verbose else logging.INFO)
242    logger_plex_websocket.propagate = False
243    logger_plex_websocket.setLevel(logging.DEBUG if verbose else logging.INFO)
244    cherrypy.log.error_log.propagate = False
245
246    # Setup file logger
247    if log_dir:
248        file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%Y-%m-%d %H:%M:%S')
249
250        # Main Tautulli logger
251        filename = os.path.join(log_dir, FILENAME)
252        file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8')
253        file_handler.setLevel(logging.DEBUG)
254        file_handler.setFormatter(file_formatter)
255
256        logger.addHandler(file_handler)
257        cherrypy.log.error_log.addHandler(file_handler)
258
259        # Tautulli API logger
260        filename = os.path.join(log_dir, FILENAME_API)
261        file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8')
262        file_handler.setLevel(logging.DEBUG)
263        file_handler.setFormatter(file_formatter)
264
265        logger_api.addHandler(file_handler)
266
267        # Tautulli websocket logger
268        filename = os.path.join(log_dir, FILENAME_PLEX_WEBSOCKET)
269        file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8')
270        file_handler.setLevel(logging.DEBUG)
271        file_handler.setFormatter(file_formatter)
272
273        logger_plex_websocket.addHandler(file_handler)
274
275    # Setup console logger
276    if console:
277        console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', '%Y-%m-%d %H:%M:%S')
278        console_handler = logging.StreamHandler()
279        console_handler.setFormatter(console_formatter)
280        console_handler.setLevel(logging.DEBUG)
281
282        logger.addHandler(console_handler)
283        cherrypy.log.error_log.addHandler(console_handler)
284
285    # Add filters to log handlers
286    # Only add filters after the config file has been initialized
287    # Nothing prior to initialization should contain sensitive information
288    if not plexpy.DEV and plexpy.CONFIG:
289        log_handlers = logger.handlers + \
290                       logger_api.handlers + \
291                       logger_plex_websocket.handlers + \
292                       cherrypy.log.error_log.handlers
293        for handler in log_handlers:
294            handler.addFilter(BlacklistFilter())
295            handler.addFilter(PublicIPFilter())
296            handler.addFilter(EmailFilter())
297            handler.addFilter(PlexTokenFilter())
298
299    # Install exception hooks
300    initHooks()
301
302
303def initHooks(global_exceptions=True, thread_exceptions=True, pass_original=True):
304    """
305    This method installs exception catching mechanisms. Any exception caught
306    will pass through the exception hook, and will be logged to the logger as
307    an error. Additionally, a traceback is provided.
308
309    This is very useful for crashing threads and any other bugs, that may not
310    be exposed when running as daemon.
311
312    The default exception hook is still considered, if pass_original is True.
313    """
314
315    def excepthook(*exception_info):
316        # We should always catch this to prevent loops!
317        try:
318            message = "".join(traceback.format_exception(*exception_info))
319            logger.error("Uncaught exception: %s", message)
320        except:
321            pass
322
323        # Original excepthook
324        if pass_original:
325            sys.__excepthook__(*exception_info)
326
327    # Global exception hook
328    if global_exceptions:
329        sys.excepthook = excepthook
330
331    # Thread exception hook
332    if thread_exceptions:
333        old_init = threading.Thread.__init__
334
335        def new_init(self, *args, **kwargs):
336            old_init(self, *args, **kwargs)
337            old_run = self.run
338
339            def new_run(*args, **kwargs):
340                try:
341                    old_run(*args, **kwargs)
342                except (KeyboardInterrupt, SystemExit):
343                    raise
344                except:
345                    excepthook(*sys.exc_info())
346            self.run = new_run
347
348        # Monkey patch the run() by monkey patching the __init__ method
349        threading.Thread.__init__ = new_init
350
351
352def shutdown():
353    logging.shutdown()
354
355
356# Expose logger methods
357# Main Tautulli logger
358info = logger.info
359warn = logger.warning
360error = logger.error
361debug = logger.debug
362warning = logger.warning
363exception = logger.exception
364
365# Tautulli API logger
366api_info = logger_api.info
367api_warn = logger_api.warning
368api_error = logger_api.error
369api_debug = logger_api.debug
370api_warning = logger_api.warning
371api_exception = logger_api.exception
372
373# Tautulli websocket logger
374websocket_info = logger_plex_websocket.info
375websocket_warn = logger_plex_websocket.warning
376websocket_error = logger_plex_websocket.error
377websocket_debug = logger_plex_websocket.debug
378websocket_warning = logger_plex_websocket.warning
379websocket_exception = logger_plex_websocket.exception
380