1
1
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
2
2
__copyright__ = "Copyright (C) 2020 The OctoPrint Project - Released under terms of the AGPLv3 License"
3
3
4
+ import os
4
5
import re
6
+ import threading
7
+ from collections import defaultdict
5
8
6
9
# noinspection PyCompatibility
7
10
from concurrent .futures import ThreadPoolExecutor
8
11
12
+ import flask
13
+ import octoprint .access .permissions
9
14
import octoprint .events
10
15
import octoprint .plugin
11
16
import sarge
12
17
from 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
+ }
13
29
14
30
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
+ ):
16
37
def __init__ (self ):
17
38
self ._executor = ThreadPoolExecutor ()
18
39
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
+
19
53
##~~ AssetPlugin API
20
54
21
55
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
+ }
23
64
24
65
##~~ EventHandlerPlugin API
25
66
@@ -29,6 +70,66 @@ def on_event(self, event, payload):
29
70
self ._validate_file , payload ["storage" ], payload ["path" ], payload ["type" ]
30
71
)
31
72
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
+
32
133
##~~ SoftwareUpdate hook
33
134
34
135
def get_update_information (self ):
@@ -60,48 +161,112 @@ def get_update_information(self):
60
161
61
162
##~~ Internal logic & helpers
62
163
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
+
63
221
def _validate_file (self , storage , path , file_type ):
64
222
try :
65
223
path_on_disk = self ._file_manager .path_on_disk (storage , path )
66
224
except NotImplementedError :
67
225
# storage doesn't support path_on_disk, ignore
68
226
return
69
227
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
73
230
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
+ )
79
249
compiled = re .compile (pattern )
80
250
81
251
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
+
101
266
except Exception :
102
267
self ._logger .exception (
103
268
"Something unexpectedly went wrong while trying to "
104
- "search for {} in {} via grep " .format (term , path )
269
+ "search for {} in {}" .format (pattern , path )
105
270
)
106
271
107
272
return False
@@ -113,20 +278,42 @@ def _search_through_file_python(self, path, term, compiled, incl_comments=False)
113
278
return True
114
279
return False
115
280
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"
121
295
)
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 },
122
307
)
308
+
309
+ def _trigger_check_update (self ):
123
310
self ._plugin_manager .send_plugin_message (
124
- self ._identifier , dict (type = notification_type , storage = storage , path = path )
311
+ self ._identifier , dict (action = "check_update" )
125
312
)
126
313
127
314
128
315
__plugin_name__ = "File Check"
129
- __plugin_pythoncompat__ = ">2 .7,<4"
316
+ __plugin_pythoncompat__ = ">3 .7,<4"
130
317
__plugin_disabling_discouraged__ = gettext (
131
318
"Without this plugin OctoPrint will no longer be able to "
132
319
"check if uploaded files contain common problems and inform you "
0 commit comments