11__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
22__copyright__ = "Copyright (C) 2020 The OctoPrint Project - Released under terms of the AGPLv3 License"
33
4+ import os
45import re
6+ import threading
7+ from collections import defaultdict
58
69# noinspection PyCompatibility
710from concurrent .futures import ThreadPoolExecutor
811
12+ import flask
13+ import octoprint .access .permissions
914import octoprint .events
1015import octoprint .plugin
1116import sarge
1217from flask_babel import gettext
18+ from octoprint .filemanager import get_file_type
19+
20+ CHECKS = {
21+ "travel_speed" : {
22+ "pattern" : "{travel_speed}" ,
23+ },
24+ "leaked_api_key" : {
25+ "pattern" : r";\s+printhost_apikey\s+=\s+\S+" ,
26+ "regex" : True ,
27+ },
28+ }
1329
1430
15- class FileCheckPlugin (octoprint .plugin .AssetPlugin , octoprint .plugin .EventHandlerPlugin ):
31+ class FileCheckPlugin (
32+ octoprint .plugin .AssetPlugin ,
33+ octoprint .plugin .EventHandlerPlugin ,
34+ octoprint .plugin .SettingsPlugin ,
35+ octoprint .plugin .SimpleApiPlugin ,
36+ ):
1637 def __init__ (self ):
1738 self ._executor = ThreadPoolExecutor ()
1839
40+ self ._native_grep_available = True
41+
42+ self ._full_check_lock = threading .RLock ()
43+ self ._check_result = {}
44+
45+ def initialize (self ):
46+ try :
47+ sarge .run (["grep" , "-q" , "--version" ])
48+ except Exception as exc :
49+ if "Command not found" in str (exc ):
50+ self ._native_grep_available = False
51+ self ._logger .info (f"Native grep available: { self ._native_grep_available } " )
52+
1953 ##~~ AssetPlugin API
2054
2155 def get_assets (self ):
22- return dict (js = ("js/file_check.js" ,))
56+ return {
57+ "js" : [
58+ "js/file_check.js" ,
59+ ],
60+ "clientjs" : [
61+ "clientjs/file_check.js" ,
62+ ],
63+ }
2364
2465 ##~~ EventHandlerPlugin API
2566
@@ -29,6 +70,66 @@ def on_event(self, event, payload):
2970 self ._validate_file , payload ["storage" ], payload ["path" ], payload ["type" ]
3071 )
3172
73+ elif event == octoprint .events .Events .FILE_SELECTED :
74+ file_type = get_file_type (payload ["name" ])
75+ self ._executor .submit (
76+ self ._validate_file , payload ["origin" ], payload ["path" ], file_type
77+ )
78+
79+ elif event == octoprint .events .Events .FILE_REMOVED :
80+ dirty = True
81+ with self ._full_check_lock :
82+ for check in self ._check_result :
83+ current = len (self ._check_result [check ])
84+ self ._check_result [check ] = [
85+ path
86+ for path in self ._check_result [check ]
87+ if path != f"{ payload ['storage' ]} :{ payload ['path' ]} "
88+ ]
89+ dirty = dirty or len (self ._check_result [check ]) < current
90+ if dirty :
91+ self ._trigger_check_update ()
92+
93+ elif event == octoprint .events .Events .FOLDER_REMOVED :
94+ dirty = False
95+ with self ._full_check_lock :
96+ for check in self ._check_result :
97+ current = len (self ._check_result [check ])
98+ self ._check_result [check ] = [
99+ path
100+ for path in self ._check_result [check ]
101+ if not path .startswith (f"{ payload ['storage' ]} :{ payload ['path' ]} /" )
102+ ]
103+ dirty = dirty or len (self ._check_result [check ]) < current
104+ if dirty :
105+ self ._trigger_check_update ()
106+
107+ ##~~ SimpleApiPlugin API
108+
109+ def on_api_get (self , request ):
110+ if not octoprint .access .permissions .Permissions .FILES_DOWNLOAD .can ():
111+ return flask .make_response ("Insufficient rights" , 403 )
112+
113+ response = {
114+ "native_grep" : self ._native_grep_available ,
115+ "check_result" : self ._check_result ,
116+ }
117+ return flask .jsonify (** response )
118+
119+ def get_api_commands (self ):
120+ return {"check_all" : []}
121+
122+ def on_api_command (self , command , data ):
123+ if command == "check_all" :
124+ if not octoprint .access .permissions .Permissions .FILES_DOWNLOAD .can ():
125+ return flask .make_response ("Insufficient rights" , 403 )
126+
127+ self ._start_full_check ()
128+ return flask .Response (
129+ status = 202 ,
130+ headers = {"Location" : flask .url_for ("index" ) + "api/plugin/file_check" },
131+ )
132+
32133 ##~~ SoftwareUpdate hook
33134
34135 def get_update_information (self ):
@@ -60,48 +161,112 @@ def get_update_information(self):
60161
61162 ##~~ Internal logic & helpers
62163
164+ def _start_full_check (self ):
165+ with self ._full_check_lock :
166+ self ._check_result = None
167+ job = self ._executor .submit (self ._check_all_files )
168+ job .add_done_callback (self ._full_check_done )
169+
170+ def _full_check_done (self , future ):
171+ try :
172+ result = future .result ()
173+ except Exception :
174+ self ._logger .exception ("Full check failed" )
175+ return
176+
177+ path_to_checks = defaultdict (list )
178+ for check , matches in result .items ():
179+ for match in matches :
180+ path_to_checks [match ].append (check )
181+
182+ self ._trigger_check_update ()
183+
184+ def _check_all_files (self ):
185+ with self ._full_check_lock :
186+ if not self ._native_grep_available :
187+ return {}
188+
189+ path = self ._settings .global_get_basefolder ("uploads" )
190+ self ._logger .info (f"Running check on all files in { path } (local storage)" )
191+
192+ full_check_result = {}
193+ for check , params in CHECKS .items ():
194+ self ._logger .info (f"Running check { check } " )
195+ pattern = params ["pattern" ]
196+ sanitized = self ._sanitize_pattern (
197+ pattern ,
198+ incl_comments = params .get ("incl_comments" , False ),
199+ regex = params .get ("regex" , False ),
200+ )
201+
202+ result = sarge .capture_both (["grep" , "-r" , "-E" , sanitized , path ])
203+ if result .stderr .text :
204+ self ._logger .warning (
205+ f"Error raised by native grep, can't run check { check } on all files"
206+ )
207+ continue
208+
209+ matches = []
210+ if result .returncode == 0 :
211+ for line in result .stdout .text .splitlines ():
212+ p , _ = line .split (":" , 1 )
213+ matches .append ("local:" + p .replace (path + os .path .sep , "" ))
214+
215+ self ._logger .info (f"... got { len (matches )} matches" )
216+ full_check_result [check ] = matches
217+
218+ self ._check_result = full_check_result
219+ return full_check_result
220+
63221 def _validate_file (self , storage , path , file_type ):
64222 try :
65223 path_on_disk = self ._file_manager .path_on_disk (storage , path )
66224 except NotImplementedError :
67225 # storage doesn't support path_on_disk, ignore
68226 return
69227
70- if file_type [- 1 ] == "gcode" :
71- if self ._search_through_file (path_on_disk , "{travel_speed}" ):
72- self ._notify ("travel_speed" , storage , path )
228+ if file_type [- 1 ] != "gcode" :
229+ return
73230
74- def _search_through_file (self , path , term , incl_comments = False ):
75- if incl_comments :
76- pattern = re .escape (term )
77- else :
78- pattern = r"^[^;]*" + re .escape (term )
231+ types = []
232+ for check , params in CHECKS .items ():
233+ pattern = params ["pattern" ]
234+ if self ._search_through_file (
235+ path_on_disk ,
236+ pattern ,
237+ incl_comments = params .get ("incl_comments" , False ),
238+ regex = params .get ("regex" , False ),
239+ ):
240+ types .append (check )
241+
242+ if types :
243+ self ._notify (storage , path , types )
244+
245+ def _search_through_file (self , path , pattern , incl_comments = False , regex = False ):
246+ sanitized = self ._sanitize_pattern (
247+ pattern , incl_comments = incl_comments , regex = regex
248+ )
79249 compiled = re .compile (pattern )
80250
81251 try :
82- try :
83- # try native grep
84- result = sarge .capture_stderr (["grep" , "-q" , "-E" , pattern , path ])
85- if result .stderr .text :
86- self ._logger .warning (
87- "Error raised by native grep, falling back to python "
88- "implementation: {}" .format (result .stderr .text .strip ())
89- )
90- return self ._search_through_file_python (
91- path , term , compiled , incl_comments = incl_comments
92- )
93- return result .returncode == 0
94- except ValueError as exc :
95- if "Command not found" in str (exc ):
96- return self ._search_through_file_python (
97- path , term , compiled , incl_comments = incl_comments
98- )
99- else :
100- raise
252+ if self ._native_grep_available :
253+ result = sarge .capture_stderr (["grep" , "-q" , "-E" , sanitized , path ])
254+ if not result .stderr .text :
255+ return result .returncode == 0
256+
257+ self ._logger .warning (
258+ "Error raised by native grep, falling back to python "
259+ "implementation: {}" .format (result .stderr .text .strip ())
260+ )
261+
262+ return self ._search_through_file_python (
263+ path , sanitized , compiled , incl_comments = incl_comments
264+ )
265+
101266 except Exception :
102267 self ._logger .exception (
103268 "Something unexpectedly went wrong while trying to "
104- "search for {} in {} via grep " .format (term , path )
269+ "search for {} in {}" .format (pattern , path )
105270 )
106271
107272 return False
@@ -113,20 +278,42 @@ def _search_through_file_python(self, path, term, compiled, incl_comments=False)
113278 return True
114279 return False
115280
116- def _notify (self , notification_type , storage , path ):
117- self ._logger .warning (
118- "File check identified an issue: {} for {}:{}, see "
119- "https://faq.octoprint.org/file-check-{} for details" .format (
120- notification_type , storage , path , notification_type .replace ("_" , "-" )
281+ def _sanitize_pattern (self , pattern , incl_comments = False , regex = False ):
282+ if regex :
283+ return pattern
284+
285+ if incl_comments :
286+ return re .escape (pattern )
287+ else :
288+ return r"^[^;]*" + re .escape (pattern )
289+
290+ def _notify (self , storage , path , types ):
291+ self ._logger .warning (f"File check identified issues for { storage } :{ path } :" )
292+ for t in types :
293+ self ._logger .warning (
294+ f" { t } , see https://faq.octoprint.org/file-check-{ t .replace ('_' , '-' )} for details"
121295 )
296+
297+ with self ._full_check_lock :
298+ for t in types :
299+ if t not in self ._check_result :
300+ self ._check_result [t ] = []
301+ if path not in self ._check_result [t ]:
302+ self ._check_result [t ].append (f"{ storage } :{ path } " )
303+
304+ self ._plugin_manager .send_plugin_message (
305+ self ._identifier ,
306+ {"action" : "notify" , "storage" : storage , "path" : path , "types" : types },
122307 )
308+
309+ def _trigger_check_update (self ):
123310 self ._plugin_manager .send_plugin_message (
124- self ._identifier , dict (type = notification_type , storage = storage , path = path )
311+ self ._identifier , dict (action = "check_update" )
125312 )
126313
127314
128315__plugin_name__ = "File Check"
129- __plugin_pythoncompat__ = ">2 .7,<4"
316+ __plugin_pythoncompat__ = ">3 .7,<4"
130317__plugin_disabling_discouraged__ = gettext (
131318 "Without this plugin OctoPrint will no longer be able to "
132319 "check if uploaded files contain common problems and inform you "
0 commit comments