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 glob
5+ import json
46import os
57import re
68import threading
9+ import time
710from collections import defaultdict
811
912# noinspection PyCompatibility
1821from octoprint .access import ADMIN_GROUP , USER_GROUP
1922from octoprint .filemanager import get_file_type
2023
24+ WIZARD_VERSION = 1 # bump on addition of critical checks
25+
26+ CHECKS_VERSION = 1 # bump on any change to the checks
2127CHECKS = {
2228 "travel_speed" : {
2329 "pattern" : "{travel_speed}" ,
@@ -34,18 +40,21 @@ class FileCheckPlugin(
3440 octoprint .plugin .EventHandlerPlugin ,
3541 octoprint .plugin .SettingsPlugin ,
3642 octoprint .plugin .SimpleApiPlugin ,
43+ octoprint .plugin .TemplatePlugin ,
44+ octoprint .plugin .WizardPlugin ,
3745):
3846 def __init__ (self ):
3947 self ._executor = ThreadPoolExecutor ()
4048
41- self ._native_grep_available = True
49+ self ._native_grep_available = False
4250
4351 self ._full_check_lock = threading .RLock ()
4452 self ._check_result = {}
4553
4654 def initialize (self ):
4755 try :
4856 sarge .run (["grep" , "-q" , "--version" ])
57+ self ._native_grep_available = True
4958 except Exception as exc :
5059 if "Command not found" in str (exc ):
5160 self ._native_grep_available = False
@@ -77,44 +86,29 @@ def on_event(self, event, payload):
7786 self ._validate_file , payload ["origin" ], payload ["path" ], file_type
7887 )
7988
80- elif event == octoprint .events .Events .FILE_REMOVED :
81- dirty = True
82- with self ._full_check_lock :
83- for check in self ._check_result :
84- current = len (self ._check_result [check ])
85- self ._check_result [check ] = [
86- path
87- for path in self ._check_result [check ]
88- if path != f"{ payload ['storage' ]} :{ payload ['path' ]} "
89- ]
90- dirty = dirty or len (self ._check_result [check ]) < current
91- if dirty :
92- self ._trigger_check_update ()
93-
94- elif event == octoprint .events .Events .FOLDER_REMOVED :
95- dirty = False
96- with self ._full_check_lock :
97- for check in self ._check_result :
98- current = len (self ._check_result [check ])
99- self ._check_result [check ] = [
100- path
101- for path in self ._check_result [check ]
102- if not path .startswith (f"{ payload ['storage' ]} :{ payload ['path' ]} /" )
103- ]
104- dirty = dirty or len (self ._check_result [check ]) < current
105- if dirty :
106- self ._trigger_check_update ()
107-
10889 ##~~ SimpleApiPlugin API
10990
11091 def on_api_get (self , request ):
11192 if not octoprint .access .permissions .Permissions .PLUGIN_FILE_CHECK_RUN .can ():
11293 return flask .make_response ("Insufficient rights" , 403 )
11394
95+ last_check_info = self ._load_last_check_info ()
96+
11497 response = {
11598 "native_grep" : self ._native_grep_available ,
116- "check_result" : self ._check_result ,
99+ "last_full_check" : {
100+ "timestamp" : last_check_info .get ("timestamp" ),
101+ "current" : last_check_info .get ("version" ) == CHECKS_VERSION ,
102+ },
117103 }
104+
105+ if octoprint .access .permissions .Permissions .FILES_LIST .can ():
106+ # only return the check result if the user has permissions
107+ # to see a file list, otherwise we might leak data
108+ response [
109+ "check_result"
110+ ] = self ._gather_from_local_metadata () # TODO: caching?
111+
118112 return flask .jsonify (** response )
119113
120114 def get_api_commands (self ):
@@ -131,6 +125,39 @@ def on_api_command(self, command, data):
131125 headers = {"Location" : flask .url_for ("index" ) + "api/plugin/file_check" },
132126 )
133127
128+ ##~~ TemplatePlugin API
129+
130+ def get_template_configs (self ):
131+ if not self ._native_grep_available :
132+ return []
133+
134+ return [
135+ dict (
136+ type = "wizard" ,
137+ template = "file_check_wizard_grep.jinja2" ,
138+ custom_bindings = True ,
139+ ),
140+ dict (
141+ type = "settings" ,
142+ template = "file_check_settings_grep.jinja2" ,
143+ custom_bindings = True ,
144+ ),
145+ ]
146+
147+ ##~~ WizardPlugin API
148+
149+ def is_wizard_required (self ):
150+ last_check_info = self ._load_last_check_info ()
151+ first_run = self ._settings .global_get_boolean (["server" , "firstRun" ])
152+ return (
153+ self ._native_grep_available
154+ and last_check_info .get ("version" ) != CHECKS_VERSION
155+ and not first_run
156+ )
157+
158+ def get_wizard_version (self ):
159+ return WIZARD_VERSION
160+
134161 ##~~ Additional permissions hook
135162
136163 def get_additional_permissions (self ):
@@ -177,33 +204,26 @@ def get_update_information(self):
177204
178205 def _start_full_check (self ):
179206 with self ._full_check_lock :
180- self ._check_result = None
181207 job = self ._executor .submit (self ._check_all_files )
182208 job .add_done_callback (self ._full_check_done )
183209
184210 def _full_check_done (self , future ):
185211 try :
186- result = future .result ()
212+ future .result ()
187213 except Exception :
188214 self ._logger .exception ("Full check failed" )
189215 return
190-
191- path_to_checks = defaultdict (list )
192- for check , matches in result .items ():
193- for match in matches :
194- path_to_checks [match ].append (check )
195-
196216 self ._trigger_check_update ()
197217
198218 def _check_all_files (self ):
199- with self ._full_check_lock :
200- if not self ._native_grep_available :
201- return {}
219+ if not self ._native_grep_available :
220+ return {}
202221
222+ with self ._full_check_lock :
203223 path = self ._settings .global_get_basefolder ("uploads" )
204224 self ._logger .info (f"Running check on all files in { path } (local storage)" )
205225
206- full_check_result = {}
226+ full_check_result = defaultdict ( list )
207227 for check , params in CHECKS .items ():
208228 self ._logger .info (f"Running check { check } " )
209229 pattern = params ["pattern" ]
@@ -224,13 +244,56 @@ def _check_all_files(self):
224244 if result .returncode == 0 :
225245 for line in result .stdout .text .splitlines ():
226246 p , _ = line .split (":" , 1 )
227- matches .append ("local:" + p .replace (path + os .path .sep , "" ))
247+ match = p .replace (path + os .path .sep , "" )
248+ if get_file_type (match )[- 1 ] == "gcode" :
249+ matches .append (match )
228250
229251 self ._logger .info (f"... got { len (matches )} matches" )
230- full_check_result [check ] = matches
252+ for match in matches :
253+ full_check_result [match ].append (check )
254+
255+ for f , checks in full_check_result .items ():
256+ self ._save_to_metadata ("local" , f , checks )
257+ self ._save_last_check_info ()
258+
259+ def _save_last_check_info (self ):
260+ data = {
261+ "version" : CHECKS_VERSION ,
262+ "timestamp" : int (time .time ()),
263+ }
264+
265+ try :
266+ with open (
267+ os .path .join (self .get_plugin_data_folder (), "last_check_info.json" ),
268+ "w" ,
269+ encoding = "utf-8" ,
270+ ) as f :
271+ data = json .dump (data , f )
272+ except Exception :
273+ self ._logger .exception (
274+ "Could not save information about last full file check"
275+ )
276+ return
277+
278+ def _load_last_check_info (self ):
279+ path = os .path .join (self .get_plugin_data_folder (), "last_check_info.json" )
280+ if not os .path .isfile (path ):
281+ return {}
282+
283+ try :
284+ with open (
285+ path ,
286+ encoding = "utf-8" ,
287+ ) as f :
288+ data = json .load (f )
289+ if isinstance (data , dict ) and "version" in data and "timestamp" in data :
290+ return data
291+ except Exception :
292+ self ._logger .exception (
293+ "Could not load information about last full file check"
294+ )
231295
232- self ._check_result = full_check_result
233- return full_check_result
296+ return {}
234297
235298 def _validate_file (self , storage , path , file_type ):
236299 try :
@@ -254,6 +317,7 @@ def _validate_file(self, storage, path, file_type):
254317 types .append (check )
255318
256319 if types :
320+ self ._save_to_metadata (storage , path , types )
257321 self ._notify (storage , path , types )
258322
259323 def _search_through_file (self , path , pattern , incl_comments = False , regex = False ):
@@ -308,23 +372,56 @@ def _notify(self, storage, path, types):
308372 f" { t } , see https://faq.octoprint.org/file-check-{ t .replace ('_' , '-' )} for details"
309373 )
310374
311- with self ._full_check_lock :
312- for t in types :
313- if t not in self ._check_result :
314- self ._check_result [t ] = []
315- if path not in self ._check_result [t ]:
316- self ._check_result [t ].append (f"{ storage } :{ path } " )
317-
318375 self ._plugin_manager .send_plugin_message (
319376 self ._identifier ,
320377 {"action" : "notify" , "storage" : storage , "path" : path , "types" : types },
321378 )
322379
323380 def _trigger_check_update (self ):
324381 self ._plugin_manager .send_plugin_message (
325- self ._identifier , dict (action = "check_update" )
382+ self ._identifier , {"action" : "check_update" }
383+ )
384+
385+ def _save_to_metadata (self , storage , path , positive_checks ):
386+ metadata = {
387+ "version" : CHECKS_VERSION ,
388+ "checks" : positive_checks ,
389+ }
390+ self ._file_manager .set_additional_metadata (
391+ storage , path , "file_check" , metadata , overwrite = True
326392 )
327393
394+ def _gather_from_local_metadata (self ):
395+ uploads = self ._settings .global_get_basefolder ("uploads" )
396+
397+ result = {}
398+ for path in glob .glob (
399+ os .path .join (uploads , "**" , ".metadata.json" ), recursive = True
400+ ):
401+ internal_path = path [len (uploads ) + 1 : - len (".metadata.json" )]
402+ from_metadata = self ._gather_metadata_from_file (path )
403+ result .update (
404+ {f"local:{ internal_path } { k } " : v for k , v in from_metadata .items ()}
405+ )
406+ return result
407+
408+ def _gather_metadata_from_file (self , path ):
409+ with open (path , encoding = "utf-8" ) as f :
410+ metadata = json .load (f )
411+
412+ if not isinstance (metadata , dict ):
413+ return {}
414+
415+ result = {}
416+ for key , value in metadata .items ():
417+ if (
418+ "file_check" in value
419+ and isinstance (value ["file_check" ], dict )
420+ and len (value ["file_check" ].get ("checks" , []))
421+ ):
422+ result [key ] = value ["file_check" ]["checks" ]
423+ return result
424+
328425
329426__plugin_name__ = "File Check"
330427__plugin_pythoncompat__ = ">3.7,<4"
0 commit comments